feat: 海外化改造(新加坡市场)—— 阶段 0-3
Some checks failed
Tests / test (push) Has been cancelled

按 U-Claw-海外化改造方案.md 与 范围决策记录.md 实施。这是 fork,不回上游:
海外版删掉的正是上游的中国市场默认值。

阶段 0 地基
- 下载源全部改国际:脚本/CI 61 处 + lockfile 880 条 npmmirror URL 归零
  (lockfile 那 880 条是 npm 的 resolved 字段,脚本层参数化根本绕不过它)
- 移除 install.ps1 里三个第三方 GitHub 加速代理,bundle 改直连 + SHA256 校验
  (原来只检查"文件大于 1MB"就解压运行)
- 技能内容与分发分离:skills/manifest.json 单一来源,install.sh 1170→658 行、
  install.ps1 721→546 行,两者技能内容归零
  实测原来是三份不一致:skills-cn 完整、install.sh 约 40%、install.ps1 约 17%,
  且 7 个通用技能只有 U 盘版有 —— 一键安装的用户一个能用的技能都没有
- Node 版本三种(v22.14/16/22.1)统一,新建 NODE_VERSION 单一来源
- Config 页三份合一。portable/Config.html 用根相对路径调 API 却只从 file:// 打开,
  保存功能已静默失效两个月;现缩为 120 行重定向壳
- 测试接入 CI(此前 node --test 无人运行,所有断言形同虚设)

阶段 1 双语可用
- 浏览器侧 i18n:JSON 为源、生成经典 script(file:// 下 fetch 本地 JSON 被拦)
  语言跟盘走不跟机器走:启动器写 data/.openclaw/locale.js
- 8 处硬编码 lang="zh-CN" 归零,data-i18n 覆盖 213 处,词条 en/zh 各 279 条
- B3 单框 Key:12 张模型卡 → 一个输入框,前缀识别 provider,
  服务端 /api/test-key 发 1-token 请求实测,错误映射成人话
  Key 填错到得知:从"直到对话失败"降到 ≤1 秒
- 区域格式 SG:DD/MM/YYYY、12 小时、S$、Asia/Singapore
  (ICU 在 en-SG 下把 SGD 渲染成裸 $,与美元无法区分,故自行拼 S$)
- README 内容分叉而非翻译,§1.3 证据清单逐条清零

阶段 2 降门槛
- 启动逻辑上移 lib/start.mjs:Windows-Start.bat 220→28 行、
  Mac-Start.command 235→33 行
  修掉 Mac 侧两个 bug:控制台端口硬编码 18788(回落时打开死页)、
  微信插件从未在 Mac 上安装
- U 盘根目录 23 → 3 个可点文件,其余进 advanced/
- 首启向导:语言 → 用途(7 角色,manifest 驱动)→ 密钥,答过不再问
- 三档界面,Simple 档隐藏一切技术名词
- 自动自愈:启动失败先自查自修,修不好导出脱敏诊断包
  (Doctor 从"用户要知道去点的工具"变成后台机制)

阶段 3 技能库
- 19 个英文技能,planned 归零。sg-weather / sg-transport 的端点均实测过
- SkillHub 从 56 张手写第三方卡片改为 manifest 生成:703→125 行,中文归零

其他
- origin.json 收拢所有运行时地址,tests/origin.test.mjs 保证迁移不会漏
- portable/ 下用户可见中文归零(由断言保证)
- 82 项测试

未验证(本机无 Windows / 无 pwsh):
- install.ps1、setup.ps1 约 210 行改动从未经 PowerShell 解析器
- 完整启动路径仅在假 node + 假 openclaw 上冒烟
- 8 个 .bat 的盘根推导仅静态断言
详见 U盘实测清单.md

受阻:
- 隐藏黑窗口 —— 需代码签名证书(.vbs 已被 Windows 弃用,替代方案都要签名)
- 场景卡 —— OpenClaw 上游 Dashboard 无预填 prompt 接口
- 官网 36 条 —— 上游 2026-04-14 拆到私有仓库,无权限
This commit is contained in:
2026-08-17 18:37:49 +08:00
parent 9a23aec097
commit b076815171
123 changed files with 13396 additions and 14723 deletions

553
portable/lib/start.mjs Normal file
View File

@@ -0,0 +1,553 @@
#!/usr/bin/env node
// U-Claw portable launcher.
//
// Everything the old START HERE - Windows.bat and START HERE - Mac.command did lives here, so
// the two platforms cannot drift apart again and so every user-facing string can
// be translated. The launchers are now thin shells that locate node and hand off.
//
// Two constraints drove this:
// 1. tests/windows-launchers.test.mjs requires customer-facing .bat files to be
// pure ASCII — Chinese Windows cmd.exe reads non-ASCII bytes as GBK and
// mis-parses the script. Localised text therefore cannot live in a .bat.
// 2. cmd.exe treats an unescaped ) as a block terminator, which produced a
// class of flash-exit regressions. Moving the logic out removes the hazard.
//
// Errors follow the house rule: what happened, why, and one thing to do next.
import { spawn, spawnSync, execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, cpSync, rmSync, renameSync } from 'node:fs';
import { createServer } from 'node:net';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import process from 'node:process';
import { resolveLocale, createTranslator } from './i18n.mjs';
import { buildChecks, runRepairs, writeDiagnostics } from './self-heal.mjs';
const GATEWAY_PORT_FROM = 18789;
const GATEWAY_PORT_TO = 18799;
const CONFIG_SERVER_DEFAULT_PORT = 18788;
const CONFIG_SERVER_WAIT_MS = 6000;
const GATEWAY_TOKEN = 'uclaw';
const libDir = dirname(fileURLToPath(import.meta.url));
const uclawDir = resolve(libDir, '..');
const paths = {
app: join(uclawDir, 'app'),
data: join(uclawDir, 'data'),
get core() { return join(this.app, 'core'); },
get state() { return join(this.data, '.openclaw'); },
get config() { return join(this.state, 'openclaw.json'); },
get runtimeJson() { return join(this.state, 'runtime.json'); },
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------- output
const dim = (s) => `${s}`;
const green = (s) => `${s}`;
const yellow = (s) => `${s}`;
function say(message) { process.stdout.write(` ${message}\n`); }
function ok(message) { say(green(message)); }
function note(message) { say(dim(message)); }
// Every user-facing failure gets the same three parts. Never print only a symptom.
function bail(t, key, vars = {}) {
process.stdout.write('\n');
say(yellow(t(`${key}.what`, vars)));
say(t(`${key}.why`, vars));
process.stdout.write('\n');
say(`${t(`${key}.action`, vars)}`);
process.stdout.write('\n');
process.exitCode = 1;
}
// ---------------------------------------------------------------- platform
function runtimeDirName() {
if (process.platform === 'win32') return 'node-win-x64';
if (process.platform === 'darwin') return process.arch === 'arm64' ? 'node-mac-arm64' : 'node-mac-x64';
return process.arch === 'arm64' ? 'node-linux-arm64' : 'node-linux-x64';
}
function nodeBinary(runtimeDir) {
return process.platform === 'win32'
? join(runtimeDir, 'node.exe')
: join(runtimeDir, 'bin', 'node');
}
function npmEntry(runtimeDir) {
return process.platform === 'win32'
? join(runtimeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')
: join(runtimeDir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
}
function openUrl(url) {
try {
if (process.platform === 'win32') {
// The empty "" is the window title; without it cmd treats a quoted URL as one.
spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
} else if (process.platform === 'darwin') {
spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
} else {
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
}
} catch {
// Opening a browser is a convenience, never a reason to fail startup.
}
}
// ---------------------------------------------------------------- steps
// Older drives shipped core-win / core-mac. Rename in place so one code path works.
function migrateLegacyCoreDir() {
for (const legacy of ['core-win', 'core-mac']) {
const from = join(paths.app, legacy);
if (existsSync(from) && !existsSync(paths.core)) {
try { renameSync(from, paths.core); } catch { /* keep going with the old name */ }
}
}
}
function clearMacQuarantine(t, nodeBin) {
if (process.platform !== 'darwin') return;
try {
const attrs = execFileSync('xattr', ['-l', nodeBin], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
if (!attrs.includes('com.apple.quarantine')) return;
note(t('start.quarantine_removing'));
spawnSync('xattr', ['-rd', 'com.apple.quarantine', uclawDir], { stdio: 'ignore' });
} catch {
// No xattr, or nothing quarantined — either way there is nothing to clear.
}
}
function ensureDataDirs() {
for (const dir of [paths.state, join(paths.data, 'memory'), join(paths.data, 'backups'), join(paths.data, 'logs')]) {
mkdirSync(dir, { recursive: true });
}
}
// Moves the heavy, rebuildable caches (browser profile, V8 compile cache) off the
// USB drive and onto local disk. Failing here only costs speed, so stay silent.
function applyPortableCache(t, nodeBin) {
try {
const out = execFileSync(nodeBin, [join(libDir, 'portable-cache.mjs'), paths.state, uclawDir], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
});
let cacheRoot = null;
for (const line of out.split(/\r?\n/)) {
const [key, ...rest] = line.split('=');
const value = rest.join('=');
if (key === 'UCLAW_COMPILE_CACHE_DIR') process.env.NODE_COMPILE_CACHE = value;
if (key === 'UCLAW_CACHE_ROOT') cacheRoot = value;
}
if (process.env.NODE_COMPILE_CACHE && cacheRoot) note(t('start.cache_local', { path: cacheRoot }));
} catch { /* cache stays on the drive */ }
}
function ensureConfig(t) {
if (existsSync(paths.config)) return;
const legacy = join(paths.data, 'config.json');
if (existsSync(legacy)) {
note(t('start.config_migrating'));
copyFileSync(legacy, paths.config);
ok(t('start.config_migrated'));
return;
}
note(t('start.config_creating'));
writeFileSync(
paths.config,
`${JSON.stringify({ gateway: { mode: 'local', auth: { token: GATEWAY_TOKEN } } }, null, 2)}\n`,
'utf8'
);
ok(t('start.config_created'));
}
function installDependencies(t, nodeBin, runtimeDir) {
if (existsSync(join(paths.core, 'node_modules'))) return true;
// The shipped zip has deps pre-installed; reaching here means an incomplete
// copy or a source checkout. Say "getting ready", not "node_modules not found".
note(t('start.deps_preparing'));
note(t('start.deps_slow_drive'));
const result = spawnSync(
nodeBin,
[npmEntry(runtimeDir), 'install', '--registry=https://registry.npmjs.org',
'--ignore-scripts', '--no-audit', '--no-fund', '--omit=dev'],
{
cwd: paths.core,
stdio: ['ignore', 'ignore', 'inherit'],
// Keep npm's cache inside the app so nothing is left on the host machine.
env: { ...process.env, npm_config_cache: join(paths.app, '.npm-cache') },
}
);
if (result.status !== 0 || !existsSync(join(paths.core, 'node_modules'))) return false;
ok(t('start.deps_done'));
return true;
}
// Diagnose used to be something the user had to know to run. By the time
// startup had visibly failed, anyone who did not know that had already given up.
// Now it runs by itself, and only speaks up when it cannot fix the problem.
async function healAndRetry(t, attempt) {
note(t('start.checking'));
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const checks = buildChecks({
paths,
defaultConfigPath: join(uclawDir, 'default-config.json'),
portRange: { from: GATEWAY_PORT_FROM, to: GATEWAY_PORT_TO },
stamp,
});
const applied = await runRepairs(checks);
if (applied.length > 0) {
for (const { result } of applied) note(` ${result}`);
note(t('start.repaired'));
// Silence on success is the point: a user who never learns something was
// wrong had a working product.
if (await attempt()) return { healed: true };
}
const report = await writeDiagnostics({
paths,
checks,
applied,
stamp,
versions: {
openclaw: readVersionFile('OPENCLAW_VERSION'),
node_pinned: readVersionFile('NODE_VERSION'),
},
});
return { healed: false, report };
}
function readVersionFile(name) {
for (const dir of [uclawDir, join(uclawDir, '..')]) {
try { return readFileSync(join(dir, name), 'utf8').trim(); } catch { /* try next */ }
}
return null;
}
// OpenClaw routes every fetch through HTTP_PROXY when it is set, which breaks
// self-hosted model endpoints on a corporate network. Put those hosts in NO_PROXY.
function applyNoProxy(t, nodeBin) {
try {
const out = execFileSync(nodeBin, [join(libDir, 'resolve-no-proxy.mjs'), paths.config], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (!out.startsWith('UCLAW_NO_PROXY=')) return;
const hosts = out.slice('UCLAW_NO_PROXY='.length);
if (!hosts) return;
process.env.NO_PROXY = hosts;
process.env.no_proxy = hosts;
note(t('start.no_proxy', { hosts }));
} catch { /* no proxy configured */ }
}
// Hands the resolved locale to the HTML pages. They are opened from file://,
// where fetching the drive's own openclaw.json is blocked, so they cannot work
// out the locale themselves — and falling back to navigator.language would make
// the language follow the machine instead of the drive, which is exactly the
// behaviour we do not want. A generated <script> is the one channel that works
// from both file:// and http://.
// True until the drive records a language of its own. Asking once and never
// again is the point: the answer lives on the drive, not in a browser profile.
function localeChosenOnDrive() {
return Boolean(driveSetting('locale'));
}
// The wizard runs once. Its answer decides which skills are installed and how
// much interface to show, so re-asking would quietly undo the user's setup.
function personaChosenOnDrive() {
const personas = driveSetting('personas');
return Array.isArray(personas) && personas.length > 0;
}
function driveSetting(key) {
try {
return JSON.parse(readFileSync(paths.config, 'utf8'))?.uclaw?.[key];
} catch {
return undefined;
}
}
function writeLocaleForPages(locale) {
try {
writeFileSync(
join(paths.state, 'locale.js'),
`/* GENERATED by lib/start.mjs — the drive's language, for pages opened from file://. */\nwindow.UCLAW_LOCALE = ${JSON.stringify(locale)};\n`,
'utf8'
);
} catch { /* pages fall back to the browser language */ }
}
// Records what is actually on this drive, so an updater can diff instead of
// guessing. This release deliberately does not self-update — a USB drive can be
// pulled mid-write and there is no way back from a half-applied update — but
// without a written-down inventory a later updater has nothing to compare
// against, and retrofitting one means the first upgrade is the risky one.
function writeInstalledManifest() {
const read = (file) => {
for (const dir of [uclawDir, join(uclawDir, '..')]) {
try { return readFileSync(join(dir, file), 'utf8').trim(); } catch { /* try next */ }
}
return null;
};
let skills = null;
try {
const manifest = JSON.parse(readFileSync(join(uclawDir, 'skills', 'manifest.json'), 'utf8'));
skills = {
schemaVersion: manifest.schemaVersion,
shipping: manifest.skills.filter((s) => s.status === 'shipping').map((s) => s.id),
};
} catch { /* skills may not be staged in a dev checkout */ }
const inventory = {
// Bump when the shape of this file changes, not when contents change.
schemaVersion: 1,
openclaw: read('OPENCLAW_VERSION'),
node: read('NODE_VERSION') ?? process.version,
skills,
updatePolicy: 'frozen-notify-only',
};
try {
writeFileSync(join(paths.state, 'installed.json'), `${JSON.stringify(inventory, null, 2)}\n`, 'utf8');
} catch { /* inventory is diagnostic, never a reason to fail startup */ }
}
function startUpdateCheck(nodeBin) {
let versionFile = join(uclawDir, 'OPENCLAW_VERSION');
if (!existsSync(versionFile)) versionFile = join(uclawDir, '..', 'OPENCLAW_VERSION');
if (!existsSync(versionFile)) return;
try {
spawn(nodeBin, [join(libDir, 'check-update.mjs'), versionFile, paths.state], {
detached: true, stdio: 'ignore', windowsHide: true,
}).unref();
} catch { /* update banner is optional */ }
}
// Was Windows-only before; the Mac launcher never did it, so Mac users silently
// had no WeChat channel.
function installWeChatPlugin(t) {
const src = join(paths.app, 'extensions', 'openclaw-weixin');
if (!existsSync(join(src, 'openclaw.plugin.json'))) return;
const home = process.env.USERPROFILE || process.env.HOME;
if (!home) return;
const dest = join(home, '.openclaw', 'extensions', 'openclaw-weixin');
if (existsSync(join(dest, 'openclaw.plugin.json'))) return;
try {
note(t('start.wechat_installing'));
mkdirSync(dirname(dest), { recursive: true });
cpSync(src, dest, { recursive: true });
ok(t('start.wechat_installed'));
} catch { /* channel plugin is optional */ }
}
function portFree(port) {
return new Promise((done) => {
const probe = createServer();
probe.once('error', () => done(false));
probe.once('listening', () => probe.close(() => done(true)));
probe.listen(port, '127.0.0.1');
});
}
async function findGatewayPort(t) {
for (let port = GATEWAY_PORT_FROM; port <= GATEWAY_PORT_TO; port++) {
if (await portFree(port)) return port;
note(t('start.port_in_use', { port }));
}
return null;
}
// The config server picks its own port when 18788 is taken and records the real
// one in runtime.json. The old Mac launcher hardcoded 18788 and opened a dead
// page whenever that fallback kicked in.
async function startConfigServer(t, nodeBin) {
note(t('start.config_center_starting'));
try { rmSync(paths.runtimeJson, { force: true }); } catch { /* nothing to clear */ }
const child = spawn(nodeBin, [join(uclawDir, 'config-server', 'server.js')], {
stdio: 'ignore', windowsHide: true,
});
child.unref();
const deadline = Date.now() + CONFIG_SERVER_WAIT_MS;
while (Date.now() < deadline) {
if (existsSync(paths.runtimeJson)) {
try {
const port = JSON.parse(readFileSync(paths.runtimeJson, 'utf8'))?.configServerPort;
if (port) return { child, port };
} catch { /* still being written */ }
}
await sleep(200);
}
return { child, port: CONFIG_SERVER_DEFAULT_PORT };
}
// Fallback for the splash screen. loading.html polls /ready itself and moves on
// when the gateway answers, but some browsers block fetch from a file:// page —
// those users would sit on a splash that never advances. Watch the port here
// too, and on timeout put them back in the Control Panel rather than nowhere.
// Replaces lib/wait-gateway.bat, which only ever ran on Windows.
function watchGatewayReady(port, configPort, { timeoutMs = 300_000, intervalMs = 2000 } = {}) {
const deadline = Date.now() + timeoutMs;
const timer = setInterval(async () => {
if (await portFree(port)) {
// Still free means nothing is listening yet.
if (Date.now() >= deadline) {
clearInterval(timer);
openUrl(`http://127.0.0.1:${configPort}/`);
}
return;
}
clearInterval(timer); // Gateway is up; the splash will have moved on by itself.
}, intervalMs);
timer.unref();
}
// ---------------------------------------------------------------- main
async function main() {
const locale = resolveLocale({ override: process.env.UCLAW_LOCALE, configPath: paths.config });
const t = createTranslator(locale);
process.stdout.write(`\n ${green(t('start.banner'))}\n\n`);
migrateLegacyCoreDir();
const runtimeDir = join(paths.app, 'runtime', runtimeDirName());
const nodeBin = nodeBinary(runtimeDir);
if (!existsSync(nodeBin)) return bail(t, 'error.node_missing');
if (process.platform === 'darwin' && !['arm64', 'x64'].includes(process.arch)) {
return bail(t, 'error.unsupported_arch', { arch: process.arch });
}
clearMacQuarantine(t, nodeBin);
note(t('start.node_version', { version: process.version }));
process.env.PATH = `${dirname(nodeBin)}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`;
process.env.OPENCLAW_HOME = paths.data;
process.env.OPENCLAW_STATE_DIR = paths.state;
process.env.OPENCLAW_CONFIG_PATH = paths.config;
// U-Claw opens the local dashboard directly. Advertising the gateway over mDNS
// is unnecessary and crashes the bonjour plugin on machines with VPN adapters.
process.env.OPENCLAW_DISABLE_BONJOUR = '1';
ensureDataDirs();
applyPortableCache(t, nodeBin);
ensureConfig(t);
if (!installDependencies(t, nodeBin, runtimeDir)) {
const outcome = await healAndRetry(t, async () => installDependencies(t, nodeBin, runtimeDir));
if (!outcome.healed) {
bail(t, 'error.deps_failed');
say(t('start.diagnostics_written', { path: outcome.report }));
say(t('start.diagnostics_hint'));
return;
}
}
applyNoProxy(t, nodeBin);
writeLocaleForPages(locale);
writeInstalledManifest();
startUpdateCheck(nodeBin);
installWeChatPlugin(t);
const { child: configServer, port: configPort } = await startConfigServer(t, nodeBin);
note(t('start.config_center_port', { port: configPort }));
let port = await findGatewayPort(t);
if (!port) {
const outcome = await healAndRetry(t, async () => Boolean(await findGatewayPort(t)));
port = outcome.healed ? await findGatewayPort(t) : null;
if (!port) {
configServer.kill();
bail(t, 'error.no_port', { from: GATEWAY_PORT_FROM, to: GATEWAY_PORT_TO });
if (outcome.report) {
say(t('start.diagnostics_written', { path: outcome.report }));
say(t('start.diagnostics_hint'));
}
return;
}
}
note(t('start.gateway_starting', { port }));
const openclawEntry = join(paths.core, 'node_modules', 'openclaw', 'openclaw.mjs');
const gateway = spawn(
nodeBin,
[openclawEntry, 'gateway', 'run', '--allow-unconfigured', '--force', '--port', String(port)],
{ cwd: paths.core, stdio: 'inherit', env: process.env, windowsHide: true }
);
// Open the splash first: it polls /ready itself and gives immediate feedback
// instead of a blank window while a slow drive unpacks components.
const loadingUrl = `${pathToFileURL(join(libDir, 'loading.html')).href}?port=${port}&token=${GATEWAY_TOKEN}`;
note(t('start.opening_screen'));
openUrl(loadingUrl);
// First run gets the language chooser instead of Settings; it writes the
// choice to the drive and then forwards to Settings itself.
// First run walks language → what-for → key. Each step writes its answer to
// the drive and is skipped once answered, so a second launch goes straight in.
if (!localeChosenOnDrive()) {
note(t('start.opening_language'));
openUrl(pathToFileURL(join(libDir, 'language.html')).href);
} else if (!personaChosenOnDrive()) {
note(t('start.opening_persona'));
openUrl(pathToFileURL(join(libDir, 'persona.html')).href);
} else {
note(t('start.opening_config'));
openUrl(`http://127.0.0.1:${configPort}/`);
}
try {
spawn(nodeBin, [join(libDir, 'prewarm.mjs'), String(port), GATEWAY_TOKEN], {
detached: true, stdio: 'ignore', windowsHide: true,
}).unref();
} catch { /* prewarm is best-effort */ }
watchGatewayReady(port, configPort);
process.stdout.write('\n');
ok(t('start.running_title'));
say(t('start.running_dashboard', { url: `http://127.0.0.1:${port}/#token=${GATEWAY_TOKEN}` }));
say(t('start.running_config', { url: `http://127.0.0.1:${configPort}/` }));
process.stdout.write('\n');
note(t('start.first_run_wait'));
note(t('start.running_hint'));
process.stdout.write('\n');
let stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
gateway.kill();
configServer.kill();
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
gateway.on('exit', (code) => {
configServer.kill();
process.stdout.write('\n');
// 0 is a clean stop; 3221225786 is Windows' Ctrl+C exit code.
if (!stopping && code !== 0 && code !== 3221225786) {
say(yellow(t('start.exited_unexpectedly', { code })));
}
ok(t('start.stopped'));
process.exitCode = code === 0 || stopping ? 0 : 1;
});
}
main().catch((error) => {
process.stderr.write(` ${error?.stack ?? error}\n`);
process.exitCode = 1;
});