Files
u-claw/portable/lib/start.mjs
zheng b076815171
Some checks failed
Tests / test (push) Has been cancelled
feat: 海外化改造(新加坡市场)—— 阶段 0-3
按 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 拆到私有仓库,无权限
2026-08-17 18:37:49 +08:00

554 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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;
});