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>
577 lines
21 KiB
JavaScript
577 lines
21 KiB
JavaScript
#!/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://.
|
||
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;
|
||
});
|