@@ -0,0 +1,28 @@
|
||||
const [origin,environment]=process.argv.slice(2);
|
||||
if(!origin||!['test','production'].includes(environment))throw new Error('Usage: node scripts/check-health.mjs <origin> test|production');
|
||||
async function read(path) {
|
||||
const response=await fetch(new URL(path,origin),{signal:AbortSignal.timeout(5000),redirect:'error',cache:'no-store'});
|
||||
if(response.status!==200)throw new Error(`${path} HTTP ${response.status}`);
|
||||
let text='',size=0;
|
||||
const decoder=new TextDecoder();
|
||||
for await(const part of response.body) {
|
||||
size+=part.length;if(size>1048576)throw new Error(`${path} 响应超过检查上限`);
|
||||
text+=decoder.decode(part,{stream:true});
|
||||
if(path==='/'&&text.includes('/_next/'))break;
|
||||
}
|
||||
return {text,type:response.headers.get('content-type')||''};
|
||||
}
|
||||
let healthy=false;
|
||||
for(let attempt=1;attempt<=5;attempt++) {
|
||||
try {
|
||||
const health=await read('/healthz'),status=JSON.parse(health.text);
|
||||
if(status.status!=='ok'||status.environment!==environment||status.application!=='jha-1048-hddj-web')throw new Error('/healthz 未命中本站对应环境');
|
||||
const home=await read('/');
|
||||
if(!/^text\/html(?:;|$)/i.test(home.type)||!home.text.includes('/_next/'))throw new Error('首页不是 Next HTML');
|
||||
healthy=true;console.log('/healthz 与 Next 首页检查通过');break;
|
||||
}catch(error) {
|
||||
console.error(`健康检查 ${attempt}/5 失败:${error.message}`);
|
||||
if(attempt<5)await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
}
|
||||
process.exitCode=healthy?0:1;
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Shared by deploy_test.sh / deploy_prod.sh; compatible with macOS Bash 3.2.
|
||||
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||||
step() { printf '\n▸ %s\n' "$*"; }
|
||||
need() { command -v "$1" >/dev/null || fail "缺少命令 $1"; }
|
||||
valid_host() { [[ "$1" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "$2 格式无效或未配置"; }
|
||||
valid_path() { [[ "$1" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*/?$ ]] && [[ "$1/" != *'/../'* ]] && [[ "$1/" != *'/./'* ]] || fail "$2 必须是普通绝对路径"; }
|
||||
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ] || fail "$2 必须在 1–65535 内"; }
|
||||
check_local() {
|
||||
need node; need npm
|
||||
node -e 'const [a,b]=process.versions.node.split(".").map(Number);if(a<20||a===20&&b<9)process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22.17.1'
|
||||
[ -d node_modules/next ] && [ -d node_modules/dotenv ] || fail '请先执行 npm ci 安装本地依赖'
|
||||
[ -s "config/$1.env" ] && [ -s package-lock.json ] || fail "缺少 config/$1.env 或 package-lock.json"
|
||||
}
|
||||
public_profile() {
|
||||
node --input-type=module -e 'import{readFileSync}from"node:fs";import{parse}from"dotenv";const c=parse(readFileSync(`config/${process.argv[1]}.env`));console.log(` API=${c.UPSTREAM_API_URL||c.API_BASE_URL||c.API_PROXY_TARGET} PORT=${c.PORT}`)' "$1"
|
||||
}
|
||||
lock_build() {
|
||||
[ ! -L dist ] || fail 'dist 不允许为符号链接'
|
||||
mkdir -p dist
|
||||
DEPLOY_LOCK="$SCRIPT_DIR/dist/.deploy.lock"
|
||||
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有构建/发布进程或残留锁:$DEPLOY_LOCK"
|
||||
trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Extract catalog, images, and CSS from 黄豆短剧-1组.html."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = ROOT / "黄豆短剧-1组.html"
|
||||
PUBLIC = ROOT / "public"
|
||||
IMAGES = PUBLIC / "images"
|
||||
ICONS = PUBLIC / "icons"
|
||||
STYLES = ROOT / "src" / "styles"
|
||||
DATA = ROOT / "src" / "data"
|
||||
|
||||
MIME_EXT = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
}
|
||||
|
||||
|
||||
def load() -> str:
|
||||
return HTML.read_text(errors="replace")
|
||||
|
||||
|
||||
def save_data_url(url: str, dest: Path) -> str:
|
||||
header, encoded = url.split(",", 1)
|
||||
mime = header[5:].split(";")[0]
|
||||
ext = MIME_EXT.get(mime, "bin")
|
||||
dest = dest.with_suffix("." + ext)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(base64.b64decode(encoded))
|
||||
return "/" + dest.relative_to(ROOT / "public").as_posix()
|
||||
|
||||
|
||||
def span(text: str, token: str, open_ch: str, close_ch: str) -> str:
|
||||
start = text.find(token)
|
||||
if start < 0:
|
||||
raise SystemExit(f"missing {token}")
|
||||
index = text.find(open_ch, start)
|
||||
depth = 0
|
||||
quote = ""
|
||||
escaped = False
|
||||
for offset, char in enumerate(text[index:], index):
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = ""
|
||||
continue
|
||||
if char in "\"'`":
|
||||
quote = char
|
||||
continue
|
||||
if char == open_ch:
|
||||
depth += 1
|
||||
elif char == close_ch:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[index : offset + 1]
|
||||
raise SystemExit(f"unclosed {token}")
|
||||
|
||||
|
||||
def extract_icons(chunk: str, folder: str) -> list[dict[str, str]]:
|
||||
items = []
|
||||
pattern = re.compile(r'"title"\s*:\s*"([^"]+)"\s*,\s*"src"\s*:\s*"(data:image/[^"]+)"')
|
||||
for index, (title, src) in enumerate(pattern.findall(chunk)):
|
||||
public_path = save_data_url(src, ICONS / folder / str(index))
|
||||
items.append({"title": title, "src": public_path})
|
||||
return items
|
||||
|
||||
|
||||
def extract_heroes(chunk: str) -> list[dict[str, str]]:
|
||||
heroes = []
|
||||
pattern = re.compile(
|
||||
r'"title"\s*:\s*"([^"]+)"\s*,\s*"wide"\s*:\s*"(data:image/[^"]+)"\s*,\s*"portrait"\s*:\s*"(data:image/[^"]+)"'
|
||||
)
|
||||
for index, (title, wide, portrait) in enumerate(pattern.findall(chunk)):
|
||||
heroes.append(
|
||||
{
|
||||
"title": title,
|
||||
"wide": save_data_url(wide, IMAGES / "hero" / f"{index}-wide"),
|
||||
"portrait": save_data_url(portrait, IMAGES / "hero" / f"{index}-portrait"),
|
||||
}
|
||||
)
|
||||
return heroes
|
||||
|
||||
|
||||
def extract_covers(chunk: str) -> list[str]:
|
||||
urls = re.findall(r'data:image/[^"\']+', chunk)
|
||||
return [save_data_url(url, IMAGES / "covers" / str(index)) for index, url in enumerate(urls)]
|
||||
|
||||
|
||||
def extract_topics(chunk: str) -> dict[str, str]:
|
||||
covers = {}
|
||||
pattern = re.compile(r'"([a-z]+)"\s*:\s*"(data:image/[^"]+)"')
|
||||
for key, src in pattern.findall(chunk):
|
||||
covers[key] = save_data_url(src, IMAGES / "topics" / key)
|
||||
return covers
|
||||
|
||||
|
||||
def first_src_after(text: str, marker: str) -> str:
|
||||
index = text.find(marker)
|
||||
if index < 0:
|
||||
return ""
|
||||
match = re.search(r'src="(data:image/[^"]+)"', text[index : index + 2_000_000])
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def extract_brand(text: str) -> dict[str, str]:
|
||||
markers = {
|
||||
"logo": 'class="sidebar-brand"',
|
||||
"wordmark": 'class="side-info-wordmark"',
|
||||
"coin": 'class="coin-soft-star"',
|
||||
"downloadLogo": 'class="download-brand-complete"',
|
||||
"avatar": 'class="member-avatar-image"',
|
||||
}
|
||||
brand = {}
|
||||
for name, marker in markers.items():
|
||||
src = first_src_after(text, marker)
|
||||
if src:
|
||||
folder = IMAGES if name == "avatar" else PUBLIC
|
||||
brand[name] = save_data_url(src, folder / name)
|
||||
qr = re.search(r'src="(data:image/[^"]+)"[^>]*alt="黄豆魔改短剧 App 下载演示二维码"', text)
|
||||
if not qr:
|
||||
qr = re.search(r'alt="黄豆魔改短剧 App 下载演示二维码"[^>]*src="(data:image/[^"]+)"', text)
|
||||
if qr:
|
||||
brand["qr"] = save_data_url(qr.group(1), IMAGES / "qr")
|
||||
return brand
|
||||
|
||||
|
||||
def extract_nav_icons(text: str) -> list[str]:
|
||||
start = text.find('<nav aria-label="主导航">')
|
||||
end = text.find("</nav>", start)
|
||||
return re.findall(r'<path d="([^"]+)"', text[start:end])
|
||||
|
||||
|
||||
def rewrite_links(html: str) -> str:
|
||||
html = re.sub(r'href="#info/([^"]+)"', r'href="/info/\1"', html)
|
||||
html = re.sub(r'href="#page/shorts/more/([^"]+)"', r'href="/shorts/more/\1"', html)
|
||||
html = re.sub(r'href="#page/topics/([^"]+)"', r'href="/topics/\1"', html)
|
||||
html = re.sub(r'href="#detail/(\d+)"', r'href="/drama/\1"', html)
|
||||
html = re.sub(r'href="#play/(\d+)/(\d+)"', r'href="/drama/\1/play/\2"', html)
|
||||
html = re.sub(r'href="#page/([^"]+)"', r'href="/\1"', html)
|
||||
html = html.replace('href="#"', 'href="/"')
|
||||
return html
|
||||
|
||||
|
||||
def replace_inline_images(html: str, folder: str) -> str:
|
||||
index = 0
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
nonlocal index
|
||||
path = save_data_url(match.group(0), IMAGES / folder / str(index))
|
||||
index += 1
|
||||
return path
|
||||
|
||||
return re.sub(r"data:image/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+", replace, html)
|
||||
|
||||
|
||||
def extract_info_pages(text: str) -> dict[str, dict[str, str]]:
|
||||
start = text.find("const pages = {")
|
||||
end = text.find("let lastKey", start)
|
||||
block = text[start:end]
|
||||
pattern = re.compile(
|
||||
r"'([a-z0-9\-]+)':\s*\{title:'([^']*)',\s*subtitle:'([^']*)',\s*html:`"
|
||||
)
|
||||
matches = list(pattern.finditer(block))
|
||||
pages: dict[str, dict[str, str]] = {}
|
||||
for index, match in enumerate(matches):
|
||||
html_start = match.end()
|
||||
html_end = matches[index + 1].start() if index + 1 < len(matches) else len(block)
|
||||
html = block[html_start:html_end].rsplit("`", 1)[0]
|
||||
if "${" in html:
|
||||
continue
|
||||
html = replace_inline_images(html, f"info/{match.group(1)}")
|
||||
pages[match.group(1)] = {
|
||||
"title": match.group(2),
|
||||
"subtitle": match.group(3),
|
||||
"html": rewrite_links(html),
|
||||
}
|
||||
return pages
|
||||
|
||||
|
||||
def extract_css(text: str) -> str:
|
||||
chunks = []
|
||||
for match in re.finditer(r"<style\b[^>]*>(.*?)</style>", text, flags=re.S):
|
||||
css = match.group(1)
|
||||
css = re.sub(r"url\(\s*(['\"]?)data:[^)\'\"]+\1\s*\)", "none", css)
|
||||
chunks.append(css.strip())
|
||||
css = "\n\n".join(chunk for chunk in chunks if chunk)
|
||||
css += """
|
||||
|
||||
/* SSR shell: search suggestions open with keyboard focus, without the prototype hash router. */
|
||||
#search-discovery{display:none}
|
||||
.search:focus-within #search-discovery{display:block}
|
||||
"""
|
||||
return css
|
||||
|
||||
|
||||
def script_body(text: str, sid: str) -> str:
|
||||
match = re.search(rf'<script id="{sid}"[^>]*>(.*?)</script>', text, re.S)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def apply_canonical_assets(text: str, brand: dict[str, str]) -> tuple[dict[str, str], dict[str, str], list[str]]:
|
||||
"""Logos and icons land in public/; photos stay in public/images.
|
||||
|
||||
The hdmgdj brand adapter is the 黄豆 lockup. Later template swaps still
|
||||
point at other product marks, so they are not used for this site.
|
||||
"""
|
||||
adapter = script_body(text, "hdmgdj-brand-adapter")
|
||||
adapter_urls = re.findall(r'data:image/[^"\']+', adapter)
|
||||
if len(adapter_urls) >= 3:
|
||||
brand["logo"] = save_data_url(adapter_urls[0], PUBLIC / "logo")
|
||||
brand["wordmark"] = brand["logo"]
|
||||
brand["downloadLogo"] = brand["logo"]
|
||||
brand["footerLogo"] = brand["logo"]
|
||||
brand["favicon"] = save_data_url(adapter_urls[1], PUBLIC / "favicon")
|
||||
brand["coin"] = save_data_url(adapter_urls[2], PUBLIC / "coin")
|
||||
refresh = script_body(text, "huangguo-figma-refresh-script")
|
||||
refresh_urls = re.findall(r'data:image/[^"\']+', refresh)
|
||||
if len(refresh_urls) >= 2:
|
||||
brand["search"] = save_data_url(refresh_urls[1], ICONS / "search")
|
||||
social = text[text.find('class="sidebar-social"') : text.find('class="sidebar-social"') + 8000]
|
||||
social_urls = re.findall(r'data:image/[^"\']+', social)
|
||||
if social_urls:
|
||||
brand["qq"] = save_data_url(social_urls[0], ICONS / "qq")
|
||||
heat = re.search(r'class="series-heat-icon" src="(data:image/[^"]+)"', text)
|
||||
if heat:
|
||||
brand["seriesHeat"] = save_data_url(heat.group(1), ICONS / "series-heat")
|
||||
if "avatar" in brand and brand["avatar"].startswith("/avatar."):
|
||||
brand["avatar"] = "/images/" + Path(brand["avatar"]).name
|
||||
road: dict[str, str] = {}
|
||||
road_match = re.search(r"const roadIcons=(\{.*?\});", text)
|
||||
if road_match:
|
||||
for key, src in re.findall(r'"([a-z]+)"\s*:\s*"(data:image/[^"]+)"', road_match.group(1)):
|
||||
road[key] = save_data_url(src, ICONS / "road" / key)
|
||||
returns = [
|
||||
save_data_url(src, ICONS / "return" / str(index))
|
||||
for index, src in enumerate(re.findall(r'class="return-section-icon" src="(data:image/[^"]+)"', text)[:5])
|
||||
]
|
||||
return brand, road, returns
|
||||
|
||||
|
||||
def main() -> None:
|
||||
text = load()
|
||||
dramas_source = span(text, "const dramas=", "[", "]")
|
||||
card_stats = json.loads(span(text, "const cardStats=", "[", "]"))
|
||||
covers = extract_covers(span(text, "coverImages=", "[", "]"))
|
||||
heroes = extract_heroes(span(text, "const heroMedia=", "[", "]"))
|
||||
topics = extract_topics(span(text, "const topicCoverImages=", "{", "}"))
|
||||
promo_apps = extract_icons(span(text, "const applicationIcons=", "[", "]"), "apps/promo")
|
||||
benefit_apps = extract_icons(span(text, "const benefitApplicationIcons=", "[", "]"), "apps/benefit")
|
||||
brand, road_icons, return_icons = apply_canonical_assets(text, extract_brand(text))
|
||||
nav_icons = extract_nav_icons(text)
|
||||
info_pages = extract_info_pages(text)
|
||||
css = extract_css(text)
|
||||
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
STYLES.mkdir(parents=True, exist_ok=True)
|
||||
(DATA / "dramas.js").write_text("export default " + dramas_source + ";\n", encoding="utf-8")
|
||||
catalog = {
|
||||
"cardStats": card_stats,
|
||||
"coverHeights": [914, 914, 915, 914, 914, 914],
|
||||
"covers": covers,
|
||||
"heroes": heroes,
|
||||
"topics": topics,
|
||||
"promoApps": promo_apps,
|
||||
"benefitApps": benefit_apps,
|
||||
"brand": brand,
|
||||
"navIcons": nav_icons,
|
||||
"roadIcons": road_icons,
|
||||
"returnIcons": return_icons,
|
||||
"collections": {
|
||||
"dream": {"title": "江湖好梦", "ids": [3, 5]},
|
||||
"city": {"title": "都市新生", "ids": [1, 4]},
|
||||
"twists": {"title": "高能反转", "ids": [0, 1, 2, 5]},
|
||||
"love": {"title": "怦然心动", "ids": [4, 1, 0, 3, 2, 5]},
|
||||
"growth": {"title": "重新爱自己", "ids": [1, 4, 2, 5, 0, 3]},
|
||||
},
|
||||
}
|
||||
(DATA / "catalog.json").write_text(json.dumps(catalog, ensure_ascii=False), encoding="utf-8")
|
||||
(DATA / "info-pages.json").write_text(json.dumps(info_pages, ensure_ascii=False), encoding="utf-8")
|
||||
(STYLES / "prototype.css").write_text(css, encoding="utf-8")
|
||||
print(
|
||||
"dramas",
|
||||
dramas_source.count("id:"),
|
||||
"covers",
|
||||
len(covers),
|
||||
"heroes",
|
||||
len(heroes),
|
||||
"topics",
|
||||
len(topics),
|
||||
"promo",
|
||||
len(promo_apps),
|
||||
"benefit",
|
||||
len(benefit_apps),
|
||||
"info",
|
||||
list(info_pages),
|
||||
"css",
|
||||
len(css),
|
||||
"brand",
|
||||
list(brand),
|
||||
"nav",
|
||||
len(nav_icons),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import {existsSync, readFileSync} from 'node:fs';
|
||||
import {resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {parse} from 'dotenv';
|
||||
|
||||
const projectRoot=fileURLToPath(new URL('../',import.meta.url));
|
||||
const directories={development:'.next-dev',test:'.next-test',production:'.next'};
|
||||
const commands=new Set(['dev','build','start']);
|
||||
const readConfig=file=>existsSync(file)?parse(readFileSync(file)):{};
|
||||
// 黄豆短剧配置写的是 API_PROXY_TARGET,这里归一成 1047 脚本读取的 UPSTREAM_API_URL。
|
||||
const apiConfig=values=>({...values,...(!Object.hasOwn(values,'UPSTREAM_API_URL')&&(Object.hasOwn(values,'API_BASE_URL')||Object.hasOwn(values,'API_PROXY_TARGET'))?{UPSTREAM_API_URL:values.API_BASE_URL||values.API_PROXY_TARGET}:{})});
|
||||
|
||||
/** APP_ENV selects the deployment; NODE_ENV keeps Next's development/production semantics. */
|
||||
export function nextCommand(environment='development',command='dev',args=[],{root=projectRoot,variables=process.env}={}) {
|
||||
if(!Object.hasOwn(directories,environment)||!commands.has(command))throw new Error('Usage: node scripts/next-env.mjs development|test|production dev|build|start');
|
||||
const base=resolve(root,'config',environment+'.env');
|
||||
if(!existsSync(base))throw new Error(`缺少环境配置:config/${environment}.env`);
|
||||
const env={
|
||||
...apiConfig(readConfig(base)),
|
||||
...(environment==='development'?apiConfig(readConfig(resolve(root,'.env.local'))):{}),
|
||||
...apiConfig(readConfig(resolve(root,'config',environment+'.local.env'))),
|
||||
...apiConfig(Object.fromEntries(Object.entries(variables).filter(([,value])=>value!==undefined))),
|
||||
APP_ENV:environment,
|
||||
APP_DIST_DIR:directories[environment],
|
||||
NODE_ENV:command==='dev'?'development':'production',
|
||||
};
|
||||
try {
|
||||
const value=(env.UPSTREAM_API_URL||'').trim();
|
||||
if(value==='current')env.UPSTREAM_API_URL=value;
|
||||
else{
|
||||
const api=new URL(value);
|
||||
if(!['http:','https:'].includes(api.protocol)||api.username||api.password||api.search||api.hash)throw new Error();
|
||||
env.UPSTREAM_API_URL=api.href.replace(/\/+$/,'');
|
||||
}
|
||||
env.API_BASE_URL=env.UPSTREAM_API_URL;
|
||||
}catch{throw new Error(`config/${environment}.env 的 UPSTREAM_API_URL(兼容 API_BASE_URL、API_PROXY_TARGET)必须为 current 或有效的 HTTP(S) 接口地址`);}
|
||||
const port=String(env.PORT||'26124');
|
||||
if(!/^\d+$/.test(port)||Number(port)<1||Number(port)>65535)throw new Error('PORT 必须在 1–65535 之间');
|
||||
env.PORT=port;
|
||||
const hasOption=names=>args.some(arg=>names.some(name=>arg===name||arg.startsWith(name+'=')));
|
||||
const options=command==='build'?[]:[
|
||||
...(hasOption(['--hostname','-H'])?[]:['--hostname','0.0.0.0']),
|
||||
...(hasOption(['--port','-p'])?[]:['--port',port]),
|
||||
];
|
||||
return {executable:process.execPath,args:[resolve(root,'node_modules/next/dist/bin/next'),command,...options,...args],cwd:root,env};
|
||||
}
|
||||
|
||||
export async function runNext(environment,command,args,options) {
|
||||
const spec=nextCommand(environment,command,args,options);
|
||||
const child=spawn(spec.executable,spec.args,{cwd:spec.cwd,env:spec.env,stdio:'inherit'});
|
||||
const interrupt=()=>child.kill('SIGINT'),terminate=()=>child.kill('SIGTERM');
|
||||
process.on('SIGINT',interrupt);process.on('SIGTERM',terminate);
|
||||
try {
|
||||
return await new Promise((resolve,reject)=>{
|
||||
child.once('error',reject);
|
||||
child.once('exit',(code,signal)=>resolve(code??(signal==='SIGINT'?130:143)));
|
||||
});
|
||||
}finally{process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);}
|
||||
}
|
||||
|
||||
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
|
||||
try {
|
||||
const [environment='development',command='dev',...args]=process.argv.slice(2);
|
||||
process.exitCode=await runNext(environment,command,args);
|
||||
}catch(error){console.error(error instanceof Error?error.message:'启动 Next.js 失败');process.exitCode=1;}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { parse } from "dotenv";
|
||||
|
||||
const [environment = "development", command = "dev", ...args] = process.argv.slice(2);
|
||||
if (
|
||||
!["development", "test", "production"].includes(environment) ||
|
||||
!["dev", "build", "start"].includes(command)
|
||||
) {
|
||||
throw new Error(
|
||||
"Usage: tsx scripts/next-env.ts development|test|production dev|build|start",
|
||||
);
|
||||
}
|
||||
|
||||
const config = (file: string) => (existsSync(file) ? parse(readFileSync(file)) : {});
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...config(resolve(`config/${environment}.env`)),
|
||||
...config(resolve(`config/${environment}.local.env`)),
|
||||
...process.env,
|
||||
APP_ENV: environment,
|
||||
NODE_ENV: command === "dev" ? "development" : "production",
|
||||
};
|
||||
|
||||
const nodeOptions = [childEnv.NODE_OPTIONS, "--dns-result-order=ipv4first"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
childEnv.NODE_OPTIONS = nodeOptions;
|
||||
|
||||
if (command !== "build") {
|
||||
const api =
|
||||
childEnv.API_PROXY_TARGET?.trim() || childEnv.NEXT_PUBLIC_API_BASE_URL?.trim();
|
||||
if (!api) {
|
||||
throw new Error(
|
||||
`API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填(${environment})。可写在 config/${environment}.local.env`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextBin = resolve("node_modules/next/dist/bin/next");
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
nextBin,
|
||||
command,
|
||||
...(command === "build" ? [] : ["--port", childEnv.PORT || "3000"]),
|
||||
...args,
|
||||
],
|
||||
{ stdio: "inherit", env: childEnv },
|
||||
);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => child.kill(signal));
|
||||
}
|
||||
child.on("exit", (code) => process.exit(code ?? 1));
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
exec bash "$ROOT/deploy_prod.sh" "$@"
|
||||
@@ -0,0 +1,246 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { parse } from "dotenv";
|
||||
|
||||
const environment = process.argv[2] || "production";
|
||||
if (!["development", "test", "production"].includes(environment)) {
|
||||
throw new Error("Unknown environment");
|
||||
}
|
||||
|
||||
/** 本地构建目录(test/dev 分目录,避免互相覆盖) */
|
||||
const buildDirectory =
|
||||
environment === "production" ? ".next" : environment === "test" ? ".next-test" : ".next-dev";
|
||||
/** 测试包保留 .next-test,与 1047 的 run.sh 一致;正式包统一为 .next */
|
||||
const runtimeDirectory = environment === "test" ? buildDirectory : ".next";
|
||||
const destination = resolve("dist", environment);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** 将包内 .next-test / .next-dev 路径改写为 .next(不含 node_modules) */
|
||||
async function rewriteRuntimeDistDir(root: string, from: string, to: string) {
|
||||
if (from === to) return;
|
||||
const skip = new Set(["node_modules"]);
|
||||
const walk = async (dir: string) => {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
if (skip.has(entry.name)) continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(js|mjs|cjs|json|map)$/.test(entry.name)) continue;
|
||||
const text = await readFile(path, "utf8");
|
||||
if (!text.includes(from)) continue;
|
||||
await writeFile(path, text.split(from).join(to));
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
}
|
||||
|
||||
const readConfig = async (file: string) =>
|
||||
parse(
|
||||
await readFile(file).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return Buffer.alloc(0);
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
const config = {
|
||||
...(await readConfig(resolve("config", `${environment}.env`))),
|
||||
...(await readConfig(resolve("config", `${environment}.local.env`))),
|
||||
...process.env,
|
||||
};
|
||||
const runtimePort = (config.PORT || "3000").trim() || "3000";
|
||||
|
||||
for (const directory of [resolve("dist"), destination]) {
|
||||
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (
|
||||
info &&
|
||||
(!info.isDirectory() || info.isSymbolicLink() || (await realpath(directory)) !== directory)
|
||||
) {
|
||||
throw new Error("Standalone output must be an ordinary build directory");
|
||||
}
|
||||
}
|
||||
|
||||
const buildId = (await readFile(resolve(buildDirectory, "BUILD_ID"), "utf8")).trim();
|
||||
if (!buildId) throw new Error(`${buildDirectory}/BUILD_ID 为空,请先 pnpm build`);
|
||||
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(destination, { recursive: true });
|
||||
await cp(resolve(buildDirectory, "standalone"), destination, { recursive: true });
|
||||
await cp(resolve("public"), resolve(destination, "public"), { recursive: true });
|
||||
await cp(
|
||||
resolve(buildDirectory, "static"),
|
||||
resolve(destination, buildDirectory, "static"),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// 测试/开发构建:把包内目录规范成 .next,并改写 server.js / chunks 中的 distDir
|
||||
if (buildDirectory !== runtimeDirectory) {
|
||||
const fromDir = resolve(destination, buildDirectory);
|
||||
const toDir = resolve(destination, runtimeDirectory);
|
||||
await rm(toDir, { recursive: true, force: true });
|
||||
await rename(fromDir, toDir);
|
||||
await rewriteRuntimeDistDir(destination, buildDirectory, runtimeDirectory);
|
||||
}
|
||||
|
||||
// 将 standalone server.js 默认端口改为当前环境 PORT(Next 生成时写死 3000)
|
||||
{
|
||||
const serverJsPath = resolve(destination, "server.js");
|
||||
const serverJs = await readFile(serverJsPath, "utf8");
|
||||
const nextDefaultPort =
|
||||
/const currentPort = parseInt\(process\.env\.PORT, 10\) \|\| 3000/;
|
||||
if (!nextDefaultPort.test(serverJs)) {
|
||||
throw new Error(
|
||||
"server.js 未找到默认端口写法 `parseInt(process.env.PORT, 10) || 3000`,请检查 Next standalone 产物",
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
serverJsPath,
|
||||
serverJs.replace(
|
||||
nextDefaultPort,
|
||||
`const currentPort = parseInt(process.env.PORT, 10) || ${runtimePort}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(resolve(destination, "config"), { recursive: true });
|
||||
await cp(
|
||||
resolve("config", `${environment}.env`),
|
||||
resolve(destination, "config", `${environment}.env`),
|
||||
);
|
||||
|
||||
if (environment !== "test") {
|
||||
const dotenvRoot = dirname(require.resolve("dotenv/package.json"));
|
||||
await mkdir(resolve(destination, "node_modules"), { recursive: true });
|
||||
await cp(dotenvRoot, resolve(destination, "node_modules/dotenv"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 运行包 package.json:禁止 next start,必须走 standalone server.js
|
||||
await writeFile(
|
||||
resolve(destination, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "jha-1048-hddj-web",
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
start: "node ./start.mjs",
|
||||
},
|
||||
engines: { node: ">=20.9.0" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
resolve(destination, "start.mjs"),
|
||||
`import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'dotenv';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(root);
|
||||
|
||||
if (existsSync(resolve('static'))) {
|
||||
throw new Error('Standalone must run in an isolated directory without a legacy static/ folder');
|
||||
}
|
||||
|
||||
const buildIdFile = resolve(${JSON.stringify(runtimeDirectory)}, 'BUILD_ID');
|
||||
if (!existsSync(buildIdFile)) {
|
||||
throw new Error(
|
||||
'缺少 ' + ${JSON.stringify(runtimeDirectory)} + '/BUILD_ID。请使用打包产物并用 node start.mjs 启动,不要使用 next start。',
|
||||
);
|
||||
}
|
||||
|
||||
const environment = ${JSON.stringify(environment)};
|
||||
const read = (file) => (existsSync(file) ? parse(readFileSync(file)) : {});
|
||||
const config = {
|
||||
...read(resolve('config', environment + '.env')),
|
||||
...read(resolve('config', environment + '.local.env')),
|
||||
...process.env,
|
||||
APP_ENV: environment,
|
||||
NODE_ENV: 'production',
|
||||
};
|
||||
const api = (config.API_PROXY_TARGET || config.NEXT_PUBLIC_API_BASE_URL || '').trim();
|
||||
if (!api) throw new Error('API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填');
|
||||
Object.assign(process.env, config);
|
||||
|
||||
// standalone 入口(不是 next start)
|
||||
await import('./server.js');
|
||||
`,
|
||||
);
|
||||
|
||||
if (environment === "test") {
|
||||
await cp(resolve("package.json"), resolve(destination, "package.json"));
|
||||
await cp(resolve("package-lock.json"), resolve(destination, "package-lock.json"));
|
||||
await mkdir(resolve(destination, "scripts"), { recursive: true });
|
||||
await cp(resolve("scripts/next-env.mjs"), resolve(destination, "scripts/next-env.mjs"));
|
||||
await cp(
|
||||
resolve("scripts/check-health.mjs"),
|
||||
resolve(destination, "scripts/check-health.mjs"),
|
||||
);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
resolve(destination, "ecosystem.config.cjs"),
|
||||
`/** PM2:必须 node start.mjs,禁止 next start */
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "hddj_h5",
|
||||
cwd: __dirname,
|
||||
script: "start.mjs",
|
||||
interpreter: "node",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
APP_ENV: ${JSON.stringify(environment)},
|
||||
HOSTNAME: "0.0.0.0",
|
||||
PORT: ${JSON.stringify(runtimePort)},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
);
|
||||
|
||||
const packagedBuildId = (
|
||||
await readFile(resolve(destination, runtimeDirectory, "BUILD_ID"), "utf8")
|
||||
).trim();
|
||||
if (packagedBuildId !== buildId) {
|
||||
throw new Error(`打包后 BUILD_ID 不一致:build=${buildId} packaged=${packagedBuildId}`);
|
||||
}
|
||||
|
||||
const api =
|
||||
config.API_PROXY_TARGET?.trim() || config.NEXT_PUBLIC_API_BASE_URL?.trim();
|
||||
if (!api) {
|
||||
console.warn(
|
||||
`[package-standalone] 警告:当前 ${environment} 未配置 API_PROXY_TARGET / NEXT_PUBLIC_API_BASE_URL`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Standalone package ready: ${destination}`);
|
||||
console.log(`BUILD_ID=${packagedBuildId}`);
|
||||
console.log(`PORT=${runtimePort}`);
|
||||
console.log(`启动:cd ${destination} && node start.mjs`);
|
||||
console.log(`或:pm2 start ${destination}/ecosystem.config.cjs`);
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
exec bash "$ROOT/deploy_test.sh" "$@"
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 将 dist/{production|test} 补齐目标 Linux 的 sharp 原生包,输出到
|
||||
* dist/{env}-linux-{arch}-{libc}(对齐 hkdj-web,适配 pnpm)。
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--");
|
||||
const [arch = "x64", libc = "glibc", environment = "production", ...extra] = args;
|
||||
if (
|
||||
!["x64", "arm64"].includes(arch) ||
|
||||
!["glibc", "musl"].includes(libc) ||
|
||||
!["production", "test"].includes(environment) ||
|
||||
extra.length
|
||||
) {
|
||||
throw new Error(
|
||||
"Usage: tsx scripts/prepare-production-runtime.ts [x64|arm64] [glibc|musl] [production|test]",
|
||||
);
|
||||
}
|
||||
|
||||
// package-standalone 已将运行包目录统一为 .next(含 test)
|
||||
const runtimeDirectory = ".next";
|
||||
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const dist = join(root, "dist");
|
||||
const source = join(dist, environment);
|
||||
const target = join(dist, `${environment}-linux-${arch}-${libc}`);
|
||||
const require = createRequire(join(root, "package.json"));
|
||||
|
||||
for (const directory of [dist, source]) {
|
||||
const info = await lstat(directory);
|
||||
if (info.isSymbolicLink() || !info.isDirectory() || (await realpath(directory)) !== directory) {
|
||||
throw new Error(`Unsafe build directory: ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireFile(base: string, name: string) {
|
||||
const path = join(base, name);
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.size === 0 || !(await realpath(path)).startsWith(base + "/")) {
|
||||
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const required = [
|
||||
"start.mjs",
|
||||
"server.js",
|
||||
`${runtimeDirectory}/BUILD_ID`,
|
||||
`config/${environment}.env`,
|
||||
"package.json",
|
||||
"ecosystem.config.cjs",
|
||||
];
|
||||
for (const file of required) await requireFile(source, file);
|
||||
for (const directory of [`${runtimeDirectory}/static`, "public"]) {
|
||||
if (!(await lstat(join(source, directory))).isDirectory()) {
|
||||
throw new Error(`Missing ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
const npmVersion = spawnSync("npm", ["--version"], { cwd: root, encoding: "utf8" });
|
||||
if (npmVersion.error || npmVersion.status !== 0) throw new Error("Cannot read npm version");
|
||||
const version = npmVersion.stdout.trim();
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version);
|
||||
const [major = 0, minor = 0, patch = 0] = match ? match.slice(1).map(Number) : [];
|
||||
if (major < 10 || (major === 10 && (minor < 9 || (minor === 9 && patch < 2)))) {
|
||||
throw new Error(`npm >=10.9.2 is required for cross-platform installation; found ${version}`);
|
||||
}
|
||||
|
||||
const sharpEntry = require.resolve("sharp");
|
||||
let sharpPkgPath = dirname(sharpEntry);
|
||||
while (true) {
|
||||
try {
|
||||
await readFile(join(sharpPkgPath, "package.json"));
|
||||
break;
|
||||
} catch {
|
||||
const parent = dirname(sharpPkgPath);
|
||||
if (parent === sharpPkgPath) throw new Error("无法定位 sharp 包目录");
|
||||
sharpPkgPath = parent;
|
||||
}
|
||||
}
|
||||
const sharpPkg = JSON.parse(await readFile(join(sharpPkgPath, "package.json"), "utf8")) as {
|
||||
name?: string;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
if (sharpPkg.name !== "sharp") throw new Error(`定位到的包不是 sharp:${sharpPkgPath}`);
|
||||
const sharpPlatform = `${libc === "musl" ? "linuxmusl" : "linux"}-${arch}`;
|
||||
const packages: [string, string, RegExp][] = [
|
||||
[`@img/sharp-${sharpPlatform}`, "", /\.node$/],
|
||||
[`@img/sharp-libvips-${sharpPlatform}`, "", /libvips.*\.so(?:\.\d+)*$/],
|
||||
];
|
||||
for (const entry of packages) {
|
||||
const ver = sharpPkg.optionalDependencies?.[entry[0]];
|
||||
if (!ver) throw new Error(`sharp 未声明可选依赖 ${entry[0]}`);
|
||||
entry[1] = ver;
|
||||
}
|
||||
|
||||
await rm(target, { recursive: true, force: true });
|
||||
// 必须保留 standalone/pnpm 软链布局;dereference 会拆掉 @swc/helpers 等嵌套路径
|
||||
await cp(source, target, {
|
||||
recursive: true,
|
||||
filter: (path) => {
|
||||
const name = basename(path);
|
||||
if (
|
||||
name.startsWith(".env") ||
|
||||
name.endsWith(".local.env") ||
|
||||
name.startsWith("._") ||
|
||||
name === ".DS_Store" ||
|
||||
name.endsWith(".log")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const nativeDir = await mkdtemp(join(dist, ".production-native-"));
|
||||
try {
|
||||
await writeFile(
|
||||
join(nativeDir, "package.json"),
|
||||
JSON.stringify({ name: "hddj-native-runtime", version: "1.0.0", private: true }),
|
||||
);
|
||||
const specs = packages.map(([name, ver]) => `${name}@${ver}`);
|
||||
console.log(`Installing Linux ${arch}/${libc} sharp packages with npm ${version}`);
|
||||
const install = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"install",
|
||||
...specs,
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--force",
|
||||
"--os=linux",
|
||||
`--cpu=${arch}`,
|
||||
`--libc=${libc}`,
|
||||
],
|
||||
{
|
||||
cwd: nativeDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_platform: "linux",
|
||||
npm_config_arch: arch,
|
||||
npm_config_libc: libc,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (install.error || install.status !== 0) {
|
||||
throw new Error(`Native npm install failed (${install.status ?? install.error?.message})`);
|
||||
}
|
||||
for (const [name, ver, pattern] of packages) {
|
||||
const directory = join(nativeDir, "node_modules", name);
|
||||
const metadata = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
|
||||
if (metadata.name !== name || metadata.version !== ver) {
|
||||
throw new Error(`Incorrect native package: ${name}`);
|
||||
}
|
||||
const binaries = await matchingFiles(directory, pattern);
|
||||
if (!binaries.length) throw new Error(`Native runtime binary is missing: ${name}`);
|
||||
for (const binary of binaries) await verifyELF(binary);
|
||||
await mkdirp(join(target, "node_modules", "@img"));
|
||||
await cp(directory, join(target, "node_modules", name), { recursive: true });
|
||||
}
|
||||
} finally {
|
||||
await rm(nativeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const file of [...required, "node_modules/dotenv/package.json"]) {
|
||||
await requireFile(target, file);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(target, "runtime-target.json"),
|
||||
JSON.stringify({ platform: "linux", arch, libc, environment, packaging: "standalone" }, null, 2) +
|
||||
"\n",
|
||||
);
|
||||
console.log(`Linux runtime ready: ${target}`);
|
||||
|
||||
async function mkdirp(path: string) {
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
async function matchingFiles(directory: string, pattern: RegExp): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) found.push(...(await matchingFiles(path, pattern)));
|
||||
else if (entry.isFile() && pattern.test(entry.name)) found.push(path);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function verifyELF(path: string) {
|
||||
const file = await open(path, "r");
|
||||
const header = Buffer.alloc(64);
|
||||
try {
|
||||
await file.read(header, 0, header.length, 0);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
if (
|
||||
!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
|
||||
header[4] !== 2 ||
|
||||
header[5] !== 1 ||
|
||||
header.readUInt16LE(18) !== (arch === "x64" ? 62 : 183)
|
||||
) {
|
||||
throw new Error(`Native runtime is not a Linux ${arch} ELF binary: ${path}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user