按 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:
553
portable/lib/start.mjs
Normal file
553
portable/lib/start.mjs
Normal 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) => `[2m${s}[0m`;
|
||||
const green = (s) => `[32m${s}[0m`;
|
||||
const yellow = (s) => `[33m${s}[0m`;
|
||||
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user