Files
u-claw/portable/lib/start.mjs
zheng dd8e0f55c3 fix(portable): Gemini keys, Telegram pairing UI, and config durability
Improve first-run and channel setup for non-technical users: detect newer
Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve
flow, and keep channels/models when config is rewritten. Persist uclaw
wizard state via uclaw-meta.json so restarts skip language/persona prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:45:03 +08:00

577 lines
21 KiB
JavaScript
Raw Permalink 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://.
function readUclawMeta() {
try {
const metaPath = join(paths.state, 'uclaw-meta.json');
if (!existsSync(metaPath)) return null;
return JSON.parse(readFileSync(metaPath, 'utf8'));
} catch {
return null;
}
}
function localeFromLocaleJs() {
try {
const content = readFileSync(join(paths.state, 'locale.js'), 'utf8');
const match = content.match(/UCLAW_LOCALE\s*=\s*(["'])([^"']+)\1/);
return match ? match[2] : null;
} catch {
return null;
}
}
// 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') || localeFromLocaleJs());
}
// 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 {
const fromConfig = JSON.parse(readFileSync(paths.config, 'utf8'))?.uclaw?.[key];
if (fromConfig !== undefined) return fromConfig;
} catch {
/* try sidecar */
}
const fromMeta = readUclawMeta()?.[key];
return fromMeta !== undefined ? fromMeta : 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;
});