#!/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