// Checks and repairs that run when startup fails, before bothering the user. // // Diagnose used to be a tool people had to know existed: when startup broke, you // were expected to work out that Windows-Diagnose.bat was the answer. Anyone who // did not know that had already given up. These checks now run by themselves, // and the user sees "checking…" rather than a stack trace. // // Two rules for anything added here: // 1. A repair must be safe to run when nothing is wrong. Startup can fail for // reasons none of these explain, and every check will run anyway. // 2. Nothing here may destroy user data. Files that look wrong get moved // aside with a timestamp, never deleted. import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, statSync, copyFileSync } from 'node:fs'; import { createServer } from 'node:net'; import { join, dirname } from 'node:path'; function portBusy(port) { return new Promise((done) => { const probe = createServer(); probe.once('error', () => done(true)); probe.once('listening', () => probe.close(() => done(false))); probe.listen(port, '127.0.0.1'); }); } // Moving aside beats deleting: if the diagnosis was wrong, the user still has // their file. The stamp comes from the caller so this stays deterministic. function setAside(path, stamp) { const target = `${path}.broken-${stamp}`; try { renameSync(path, target); return target; } catch { return null; } } export function buildChecks({ paths, defaultConfigPath, portRange, stamp }) { return [ { id: 'data-dirs', repair() { const wanted = [paths.state, join(paths.data, 'memory'), join(paths.data, 'backups'), join(paths.data, 'logs')]; const missing = wanted.filter((dir) => !existsSync(dir)); if (missing.length === 0) return null; missing.forEach((dir) => mkdirSync(dir, { recursive: true })); return `created ${missing.length} missing folder(s)`; }, }, { id: 'config-readable', repair() { if (!existsSync(paths.config)) return null; try { JSON.parse(readFileSync(paths.config, 'utf8')); return null; } catch { // A half-written openclaw.json stops the gateway dead, and the message // it produces means nothing to a non-technical user. const moved = setAside(paths.config, stamp); if (defaultConfigPath && existsSync(defaultConfigPath)) copyFileSync(defaultConfigPath, paths.config); else writeFileSync(paths.config, `${JSON.stringify({ gateway: { mode: 'local', auth: { token: 'uclaw' } } }, null, 2)}\n`, 'utf8'); return moved ? `settings file was damaged, kept a copy at ${moved}` : 'rebuilt the settings file'; } }, }, { id: 'stale-runtime', async repair() { if (!existsSync(paths.runtimeJson)) return null; let port = null; try { port = JSON.parse(readFileSync(paths.runtimeJson, 'utf8'))?.configServerPort; } catch { /* unreadable */ } // Left behind by a crash or an unplugged drive; it makes the launcher // wait on a port nothing is listening on. if (port && (await portBusy(port))) return null; try { rmSync(paths.runtimeJson, { force: true }); } catch { return null; } return 'cleared a leftover port record'; }, }, { id: 'openclaw-present', repair() { const modules = join(paths.core, 'node_modules'); if (!existsSync(modules)) return null; // node_modules exists but the entry point does not: a copy onto the // drive that stopped part-way. Removing it makes startup reinstall. if (existsSync(join(modules, 'openclaw', 'openclaw.mjs'))) return null; const moved = setAside(modules, stamp); return moved ? 'the copy on this drive was incomplete, will fetch it again' : null; }, }, { id: 'broken-cache-link', repair() { const browser = join(paths.state, 'browser'); if (!existsSync(browser)) return null; try { statSync(browser); // follows the link; throws when the target is gone return null; } catch { // portable-cache points this at local disk. Plug the drive into a // different machine and the link dangles, which breaks the browser. try { rmSync(browser, { force: true, recursive: true }); } catch { return null; } return 'cleared a cache link left by another computer'; } }, }, { id: 'ports-free', async repair() { const busy = []; for (let port = portRange.from; port <= portRange.to; port++) { if (await portBusy(port)) busy.push(port); } // Not repairable — killing a process we did not start is not ours to do. // Reported so the message can say "it may already be running". if (busy.length <= portRange.to - portRange.from) return null; return null; }, report: async () => { const busy = []; for (let port = portRange.from; port <= portRange.to; port++) { if (await portBusy(port)) busy.push(port); } return busy.length ? `ports in use: ${busy.join(', ')}` : 'all ports free'; }, }, ]; } export async function runRepairs(checks) { const applied = []; for (const check of checks) { try { const result = await check.repair(); if (result) applied.push({ id: check.id, result }); } catch (error) { // A failing check must never be the reason startup stops. applied.push({ id: check.id, result: `check failed: ${error.message}` }); } } return applied; } // Written when the repairs did not help. Everything a support conversation would // ask for, with the secrets taken out — the user should be able to read the file // themselves before deciding to send it. export async function writeDiagnostics({ paths, checks, applied, versions, stamp, extra = {} }) { const redact = (value) => (typeof value === 'string' && value.length > 8 ? `${value.slice(0, 6)}…redacted` : '***'); let config = 'not readable'; try { const parsed = JSON.parse(readFileSync(paths.config, 'utf8')); const walk = (node) => { if (!node || typeof node !== 'object') return node; if (Array.isArray(node)) return node.map(walk); return Object.fromEntries(Object.entries(node).map(([key, value]) => /key|token|secret|password/i.test(key) ? [key, redact(value)] : [key, walk(value)])); }; config = JSON.stringify(walk(parsed), null, 2); } catch (error) { config = `not readable: ${error.message}`; } const listing = (dir) => { try { return readdirSync(dir).sort().join(', ') || '(empty)'; } catch { return '(missing)'; } }; const reports = []; for (const check of checks) { if (typeof check.report !== 'function') continue; try { reports.push(`${check.id}: ${await check.report()}`); } catch { /* skip */ } } const lines = [ 'U-Claw diagnostics', `Written: ${stamp}`, '', 'API keys and tokens have been replaced with "redacted" — you can read this', 'file before you send it anywhere.', '', '## System', `platform: ${process.platform} ${process.arch}`, `node: ${process.version}`, ...Object.entries(versions).map(([key, value]) => `${key}: ${value ?? 'unknown'}`), ...Object.entries(extra).map(([key, value]) => `${key}: ${value}`), '', '## Repairs attempted', applied.length ? applied.map((a) => `- ${a.id}: ${a.result}`).join('\n') : '- none needed', '', '## Checks', reports.length ? reports.map((r) => `- ${r}`).join('\n') : '- none', '', '## Folders', `drive root: ${listing(dirname(paths.data))}`, `data/.openclaw: ${listing(paths.state)}`, `app/core/node_modules: ${listing(join(paths.core, 'node_modules'))}`, '', '## Settings (redacted)', config, '', ]; const target = join(paths.state, 'diagnostics.txt'); writeFileSync(target, lines.join('\n'), 'utf8'); return target; }