diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index e7f92ba3263..995e150a144 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, openSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; @@ -11,6 +11,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; import { applyCliProfileEnv } from "./profile.js"; +import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js"; const DEFAULT_IRONCLAW_PROFILE = "ironclaw"; const DEFAULT_GATEWAY_PORT = 18789; @@ -28,6 +29,7 @@ export type BootstrapCheck = { | "openclaw-cli" | "profile" | "gateway" + | "agent-auth" | "web-ui" | "state-isolation" | "daemon-label" @@ -65,6 +67,14 @@ type BootstrapSummary = { openClawVersion?: string; gatewayUrl: string; gatewayReachable: boolean; + gatewayAutoFix?: { + attempted: boolean; + recovered: boolean; + steps: GatewayAutoFixStep[]; + failureSummary?: string; + logExcerpts: GatewayLogExcerpt[]; + }; + workspaceSeed?: WorkspaceSeedResult; webUrl: string; webReachable: boolean; webOpened: boolean; @@ -77,6 +87,35 @@ type SpawnResult = { code: number; }; +type OpenClawCliAvailability = { + available: boolean; + installed: boolean; + version?: string; + command: string; + globalBinDir?: string; + shellCommandPath?: string; +}; + +type GatewayAutoFixStep = { + name: string; + ok: boolean; + detail?: string; +}; + +type GatewayLogExcerpt = { + path: string; + excerpt: string; +}; + +type GatewayAutoFixResult = { + attempted: boolean; + recovered: boolean; + steps: GatewayAutoFixStep[]; + finalProbe: { ok: boolean; detail?: string }; + failureSummary?: string; + logExcerpts: GatewayLogExcerpt[]; +}; + function resolveCommandForPlatform(command: string): string { if (process.platform !== "win32") { return command; @@ -98,17 +137,23 @@ function resolveCommandForPlatform(command: string): string { async function runCommandWithTimeout( argv: string[], - options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv }, + options: { + timeoutMs: number; + cwd?: string; + env?: NodeJS.ProcessEnv; + ioMode?: "capture" | "inherit"; + }, ): Promise { const [command, ...args] = argv; if (!command) { return { code: 1, stdout: "", stderr: "missing command" }; } + const stdio = options.ioMode === "inherit" ? "inherit" : (["ignore", "pipe", "pipe"] as const); return await new Promise((resolve, reject) => { const child = spawn(resolveCommandForPlatform(command), args, { cwd: options.cwd, env: options.env ? { ...process.env, ...options.env } : process.env, - stdio: ["ignore", "pipe", "pipe"], + stdio, }); let stdout = ""; let stderr = ""; @@ -229,9 +274,10 @@ function resolveGatewayLaunchAgentLabel(profile: string): string { return `ai.openclaw.${normalized}`; } -async function ensureGatewayModeLocal(profile: string): Promise { +async function ensureGatewayModeLocal(openclawCommand: string, profile: string): Promise { const result = await runOpenClaw( - ["openclaw", "--profile", profile, "config", "get", "gateway.mode"], + openclawCommand, + ["--profile", profile, "config", "get", "gateway.mode"], 10_000, ); const currentMode = result.stdout.trim(); @@ -239,7 +285,8 @@ async function ensureGatewayModeLocal(profile: string): Promise { return; } await runOpenClawOrThrow({ - argv: ["openclaw", "--profile", profile, "config", "set", "gateway.mode", "local"], + openclawCommand, + args: ["--profile", profile, "config", "set", "gateway.mode", "local"], timeoutMs: 10_000, errorMessage: "Failed to set gateway.mode=local.", }); @@ -325,16 +372,45 @@ function startWebAppIfNeeded(port: number, stateDir: string): void { child.unref(); } -async function runOpenClaw(argv: string[], timeoutMs: number): Promise { - return await runCommandWithTimeout(argv, { timeoutMs }); +async function runOpenClaw( + openclawCommand: string, + args: string[], + timeoutMs: number, + ioMode: "capture" | "inherit" = "capture", +): Promise { + return await runCommandWithTimeout([openclawCommand, ...args], { timeoutMs, ioMode }); } async function runOpenClawOrThrow(params: { - argv: string[]; + openclawCommand: string; + args: string[]; timeoutMs: number; errorMessage: string; }): Promise { - const result = await runOpenClaw(params.argv, params.timeoutMs); + const result = await runOpenClaw(params.openclawCommand, params.args, params.timeoutMs); + if (result.code === 0) { + return result; + } + const detail = firstNonEmptyLine(result.stderr, result.stdout); + throw new Error(detail ? `${params.errorMessage}\n${detail}` : params.errorMessage); +} + +/** + * Runs an OpenClaw command attached to the current terminal. + * Use this for interactive flows like `openclaw onboard`. + */ +async function runOpenClawInteractiveOrThrow(params: { + openclawCommand: string; + args: string[]; + timeoutMs: number; + errorMessage: string; +}): Promise { + const result = await runOpenClaw( + params.openclawCommand, + params.args, + params.timeoutMs, + "inherit", + ); if (result.code === 0) { return result; } @@ -347,7 +423,8 @@ async function runOpenClawOrThrow(params: { * from the subprocess stdout/stderr into the spinner message. */ async function runOpenClawWithProgress(params: { - argv: string[]; + openclawCommand: string; + args: string[]; timeoutMs: number; startMessage: string; successMessage: string; @@ -356,14 +433,8 @@ async function runOpenClawWithProgress(params: { const s = spinner(); s.start(params.startMessage); - const [command, ...args] = params.argv; - if (!command) { - s.stop(params.errorMessage, 1); - throw new Error(params.errorMessage); - } - const result = await new Promise((resolve, reject) => { - const child = spawn(resolveCommandForPlatform(command), args, { + const child = spawn(resolveCommandForPlatform(params.openclawCommand), params.args, { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -425,38 +496,138 @@ async function runOpenClawWithProgress(params: { throw new Error(detail ? `${params.errorMessage}\n${detail}` : params.errorMessage); } -async function ensureOpenClawCliAvailable(): Promise<{ - available: boolean; - installed: boolean; - version?: string; -}> { - const check = await runOpenClaw(["openclaw", "--version"], 4_000).catch(() => null); - if (check?.code === 0) { - return { - available: true, - installed: false, - version: normalizeVersionOutput(check.stdout || check.stderr), - }; +function parseJsonPayload(raw: string | undefined): Record | undefined { + if (!raw) { + return undefined; } + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start === -1 || end <= start) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed.slice(start, end + 1)); + return parsed && typeof parsed === "object" ? (parsed as Record) : undefined; + } catch { + return undefined; + } + } +} - const install = await runCommandWithTimeout(["npm", "install", "-g", "openclaw"], { - timeoutMs: 10 * 60_000, +async function detectGlobalOpenClawInstall(): Promise<{ installed: boolean; version?: string }> { + const result = await runCommandWithTimeout( + ["npm", "ls", "-g", "openclaw", "--depth=0", "--json", "--silent"], + { + timeoutMs: 15_000, + }, + ).catch(() => null); + + const parsed = parseJsonPayload(result?.stdout ?? result?.stderr); + const dependencies = parsed?.dependencies as + | Record + | undefined; + const installedVersion = dependencies?.openclaw?.version; + if (typeof installedVersion === "string" && installedVersion.length > 0) { + return { installed: true, version: installedVersion }; + } + return { installed: false }; +} + +async function resolveNpmGlobalBinDir(): Promise { + const result = await runCommandWithTimeout(["npm", "prefix", "-g"], { + timeoutMs: 8_000, }).catch(() => null); - if (!install || install.code !== 0) { - return { available: false, installed: false, version: undefined }; + if (!result || result.code !== 0) { + return undefined; + } + const prefix = firstNonEmptyLine(result.stdout); + if (!prefix) { + return undefined; + } + return process.platform === "win32" ? prefix : path.join(prefix, "bin"); +} + +function resolveGlobalOpenClawCommand(globalBinDir: string | undefined): string | undefined { + if (!globalBinDir) { + return undefined; + } + const candidates = + process.platform === "win32" + ? [path.join(globalBinDir, "openclaw.cmd"), path.join(globalBinDir, "openclaw.exe")] + : [path.join(globalBinDir, "openclaw")]; + return candidates.find((candidate) => existsSync(candidate)); +} + +async function resolveShellOpenClawPath(): Promise { + const locator = process.platform === "win32" ? "where" : "which"; + const result = await runCommandWithTimeout([locator, "openclaw"], { + timeoutMs: 4_000, + }).catch(() => null); + if (!result || result.code !== 0) { + return undefined; + } + return firstNonEmptyLine(result.stdout); +} + +function isProjectLocalOpenClawPath(commandPath: string | undefined): boolean { + if (!commandPath) { + return false; + } + const normalized = commandPath.replaceAll("\\", "/"); + return normalized.includes("/node_modules/.bin/openclaw"); +} + +async function ensureOpenClawCliAvailable(): Promise { + const globalBefore = await detectGlobalOpenClawInstall(); + let installed = false; + if (!globalBefore.installed) { + const install = await runCommandWithTimeout(["npm", "install", "-g", "openclaw@latest"], { + timeoutMs: 10 * 60_000, + }).catch(() => null); + if (!install || install.code !== 0) { + return { + available: false, + installed: false, + version: undefined, + command: "openclaw", + }; + } + installed = true; } - const versionCheck = await runOpenClaw(["openclaw", "--version"], 4_000).catch(() => null); + const globalAfter = installed ? await detectGlobalOpenClawInstall() : globalBefore; + const globalBinDir = await resolveNpmGlobalBinDir(); + const globalCommand = resolveGlobalOpenClawCommand(globalBinDir); + const command = globalCommand ?? "openclaw"; + const check = await runOpenClaw(command, ["--version"], 4_000).catch(() => null); + const shellCommandPath = await resolveShellOpenClawPath(); + const version = normalizeVersionOutput(check?.stdout || check?.stderr || globalAfter.version); + const available = Boolean(globalAfter.installed && check && check.code === 0); return { - available: Boolean(versionCheck && versionCheck.code === 0), - installed: true, - version: normalizeVersionOutput(versionCheck?.stdout || versionCheck?.stderr), + available, + installed, + version, + command, + globalBinDir, + shellCommandPath, }; } -async function probeGateway(profile: string): Promise<{ ok: boolean; detail?: string }> { +async function probeGateway( + openclawCommand: string, + profile: string, +): Promise<{ ok: boolean; detail?: string }> { const result = await runOpenClaw( - ["openclaw", "--profile", profile, "health", "--json"], + openclawCommand, + ["--profile", profile, "health", "--json"], 12_000, ).catch((error) => { const message = error instanceof Error ? error.message : String(error); @@ -475,6 +646,141 @@ async function probeGateway(profile: string): Promise<{ ok: boolean; detail?: st }; } +function readLogTail(logPath: string, maxLines = 16): string | undefined { + if (!existsSync(logPath)) { + return undefined; + } + try { + const lines = readFileSync(logPath, "utf-8") + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + if (lines.length === 0) { + return undefined; + } + return lines.slice(-maxLines).join("\n"); + } catch { + return undefined; + } +} + +function resolveLatestRuntimeLogPath(): string | undefined { + const runtimeLogDir = "/tmp/openclaw"; + if (!existsSync(runtimeLogDir)) { + return undefined; + } + try { + const files = readdirSync(runtimeLogDir) + .filter((name) => /^openclaw-.*\.log$/u.test(name)) + .toSorted((a, b) => b.localeCompare(a)); + if (files.length === 0) { + return undefined; + } + return path.join(runtimeLogDir, files[0]); + } catch { + return undefined; + } +} + +function collectGatewayLogExcerpts(stateDir: string): GatewayLogExcerpt[] { + const candidates = [ + path.join(stateDir, "logs", "gateway.err.log"), + path.join(stateDir, "logs", "gateway.log"), + resolveLatestRuntimeLogPath(), + ].filter((candidate): candidate is string => Boolean(candidate)); + + const excerpts: GatewayLogExcerpt[] = []; + for (const candidate of candidates) { + const excerpt = readLogTail(candidate); + if (!excerpt) { + continue; + } + excerpts.push({ path: candidate, excerpt }); + } + return excerpts; +} + +function deriveGatewayFailureSummary( + probeDetail: string | undefined, + excerpts: GatewayLogExcerpt[], +): string | undefined { + const combinedLines = excerpts.flatMap((entry) => entry.excerpt.split(/\r?\n/)); + const signalRegex = + /(cannot find module|plugin not found|invalid config|unauthorized|token mismatch|eaddrinuse|address already in use|error:|failed to|failovererror)/iu; + const likely = [...combinedLines].toReversed().find((line) => signalRegex.test(line)); + if (likely) { + return likely.length > 220 ? `${likely.slice(0, 217)}...` : likely; + } + return probeDetail; +} + +async function attemptGatewayAutoFix(params: { + openclawCommand: string; + profile: string; + stateDir: string; +}): Promise { + const steps: GatewayAutoFixStep[] = []; + const commands: Array<{ + name: string; + args: string[]; + timeoutMs: number; + }> = [ + { + name: "openclaw doctor --fix", + args: ["--profile", params.profile, "doctor", "--fix"], + timeoutMs: 2 * 60_000, + }, + { + name: "openclaw gateway install", + args: ["--profile", params.profile, "gateway", "install"], + timeoutMs: 2 * 60_000, + }, + { + name: "openclaw gateway start", + args: ["--profile", params.profile, "gateway", "start"], + timeoutMs: 2 * 60_000, + }, + ]; + + for (const command of commands) { + const result = await runOpenClaw(params.openclawCommand, command.args, command.timeoutMs).catch( + (error) => { + const message = error instanceof Error ? error.message : String(error); + return { + code: 1, + stdout: "", + stderr: message, + } as SpawnResult; + }, + ); + steps.push({ + name: command.name, + ok: result.code === 0, + detail: result.code === 0 ? undefined : firstNonEmptyLine(result.stderr, result.stdout), + }); + } + + let finalProbe = await probeGateway(params.openclawCommand, params.profile); + for (let attempt = 0; attempt < 2 && !finalProbe.ok; attempt += 1) { + await sleep(1_200); + finalProbe = await probeGateway(params.openclawCommand, params.profile); + } + + const logExcerpts = finalProbe.ok ? [] : collectGatewayLogExcerpts(params.stateDir); + const failureSummary = finalProbe.ok + ? undefined + : deriveGatewayFailureSummary(finalProbe.detail, logExcerpts); + + return { + attempted: true, + recovered: finalProbe.ok, + steps, + finalProbe, + failureSummary, + logExcerpts, + }; +} + async function openUrl(url: string): Promise { const argv = process.platform === "darwin" @@ -501,13 +807,29 @@ function remediationForGatewayFailure(detail: string | undefined, port: number): if (normalized.includes("address already in use") || normalized.includes("eaddrinuse")) { return `Port ${port} is busy. Stop the conflicting process or rerun bootstrap with \`--gateway-port \`.`; } - return "Run `openclaw doctor --fix` and retry `ironclaw bootstrap --force-onboard`."; + return "Run `openclaw --profile ironclaw doctor --fix` and retry `ironclaw bootstrap --force-onboard`."; } function remediationForWebUiFailure(port: number): string { return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`ironclaw bootstrap --web-port \` if needed.`; } +function describeWorkspaceSeedResult(result: WorkspaceSeedResult): string { + if (result.seeded) { + return `seeded ${result.dbPath}`; + } + if (result.reason === "already-exists") { + return `skipped; existing database found at ${result.dbPath}`; + } + if (result.reason === "seed-asset-missing") { + return `skipped; seed asset missing at ${result.seedDbPath}`; + } + if (result.reason === "copy-failed") { + return `failed to copy seed database: ${result.error ?? "unknown error"}`; + } + return `skipped; reason=${result.reason}`; +} + function createCheck( id: BootstrapCheck["id"], status: BootstrapCheckStatus, @@ -517,6 +839,149 @@ function createCheck( return { id, status, detail, remediation }; } +/** + * Load OpenClaw profile config from state dir. + * Supports both openclaw.json (current) and config.json (legacy). + */ +function readBootstrapConfig(stateDir: string): Record | undefined { + for (const name of ["openclaw.json", "config.json"]) { + const configPath = path.join(stateDir, name); + if (!existsSync(configPath)) { + continue; + } + try { + const raw = JSON.parse(readFileSync(configPath, "utf-8")); + if (raw && typeof raw === "object") { + return raw as Record; + } + } catch { + // Config unreadable; skip. + } + } + return undefined; +} + +function normalizeWorkspacePath( + rawPath: string, + stateDir: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const trimmed = rawPath.trim(); + if (trimmed.startsWith("~")) { + const home = resolveRequiredHomeDir(env, os.homedir); + const relative = trimmed.slice(1).replace(/^[/\\]+/, ""); + return path.resolve(home, relative); + } + if (path.isAbsolute(trimmed)) { + return path.resolve(trimmed); + } + return path.resolve(stateDir, trimmed); +} + +function resolveBootstrapWorkspaceDir( + stateDir: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const envWorkspace = env.OPENCLAW_WORKSPACE?.trim(); + if (envWorkspace) { + return normalizeWorkspacePath(envWorkspace, stateDir, env); + } + + const config = readBootstrapConfig(stateDir) as + | { agents?: { defaults?: { workspace?: unknown } } } + | undefined; + const configuredWorkspace = config?.agents?.defaults?.workspace; + if (typeof configuredWorkspace === "string" && configuredWorkspace.trim().length > 0) { + return normalizeWorkspacePath(configuredWorkspace, stateDir, env); + } + + return path.join(stateDir, "workspace"); +} + +/** + * Resolve the model provider prefix from the config's primary model string. + * e.g. "vercel-ai-gateway/anthropic/claude-opus-4.6" → "vercel-ai-gateway" + */ +function resolveModelProvider(stateDir: string): string | undefined { + const raw = readBootstrapConfig(stateDir); + const model = (raw as { agents?: { defaults?: { model?: { primary?: string } | string } } }) + ?.agents?.defaults?.model; + const modelName = typeof model === "string" ? model : model?.primary; + if (typeof modelName === "string" && modelName.includes("/")) { + return modelName.split("/")[0]; + } + return undefined; +} + +/** + * Sync bundled Dench skill into the profile-managed skills folder. + * This keeps Dench injected by default while avoiding workspace edits. + */ +function syncBundledDenchSkill(stateDir: string): { + mode: "installed" | "updated"; + targetDir: string; +} { + const targetDir = path.join(stateDir, "skills", "dench"); + const targetSkillFile = path.join(targetDir, "SKILL.md"); + const mode: "installed" | "updated" = existsSync(targetSkillFile) ? "updated" : "installed"; + const sourceDir = path.join(resolveCliPackageRoot(), "skills", "dench"); + const sourceSkillFile = path.join(sourceDir, "SKILL.md"); + if (!existsSync(sourceSkillFile)) { + throw new Error( + `Bundled Dench skill not found at ${sourceDir}. Reinstall ironclaw and rerun bootstrap.`, + ); + } + mkdirSync(path.dirname(targetDir), { recursive: true }); + // Always replace with the bundled version so ironclaw updates refresh Dench automatically. + cpSync(sourceDir, targetDir, { recursive: true, force: true }); + return { mode, targetDir }; +} + +/** + * Check if the agent auth store has at least one key for the given provider. + */ +export function checkAgentAuth( + stateDir: string, + provider: string | undefined, +): { ok: boolean; provider?: string; detail: string } { + if (!provider) { + return { ok: false, detail: "No model provider configured." }; + } + const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); + if (!existsSync(authPath)) { + return { + ok: false, + provider, + detail: `No auth-profiles.json found for agent (expected at ${authPath}).`, + }; + } + try { + const raw = JSON.parse(readFileSync(authPath, "utf-8")); + const profiles = raw?.profiles; + if (!profiles || typeof profiles !== "object") { + return { ok: false, provider, detail: `auth-profiles.json has no profiles configured.` }; + } + const hasKey = Object.values(profiles).some( + (p: unknown) => + p && + typeof p === "object" && + (p as Record).provider === provider && + typeof (p as Record).key === "string" && + ((p as Record).key as string).length > 0, + ); + if (!hasKey) { + return { + ok: false, + provider, + detail: `No API key for provider "${provider}" in agent auth store.`, + }; + } + return { ok: true, provider, detail: `API key configured for ${provider}.` }; + } catch { + return { ok: false, provider, detail: `Failed to read auth-profiles.json.` }; + } +} + export function buildBootstrapDiagnostics(params: { profile: string; openClawCliAvailable: boolean; @@ -528,6 +993,7 @@ export function buildBootstrapDiagnostics(params: { webReachable: boolean; rolloutStage: BootstrapRolloutStage; legacyFallbackEnabled: boolean; + stateDir?: string; env?: NodeJS.ProcessEnv; }): BootstrapDiagnostics { const env = params.env ?? process.env; @@ -578,6 +1044,22 @@ export function buildBootstrapDiagnostics(params: { ); } + const stateDir = params.stateDir ?? resolveProfileStateDir(params.profile, env); + const modelProvider = resolveModelProvider(stateDir); + const authCheck = checkAgentAuth(stateDir, modelProvider); + if (authCheck.ok) { + checks.push(createCheck("agent-auth", "pass", authCheck.detail)); + } else { + checks.push( + createCheck( + "agent-auth", + "fail", + authCheck.detail, + `Run \`openclaw --profile ${params.profile} onboard --install-daemon\` to configure API keys.`, + ), + ); + } + if (params.webReachable) { checks.push(createCheck("web-ui", "pass", `Web UI reachable on port ${params.webPort}.`)); } else { @@ -591,7 +1073,6 @@ export function buildBootstrapDiagnostics(params: { ); } - const stateDir = resolveProfileStateDir(params.profile, env); const defaultStateDir = path.join(resolveRequiredHomeDir(env, os.homedir), ".openclaw"); const usesIsolatedStateDir = params.profile === "default" || path.resolve(stateDir) !== path.resolve(defaultStateDir); @@ -721,61 +1202,64 @@ export async function bootstrapCommand( [ "OpenClaw CLI is required but unavailable.", "Install it with: npm install -g openclaw", - ].join("\n"), + installResult.globalBinDir + ? `Expected global binary directory: ${installResult.globalBinDir}` + : "", + ] + .filter((line) => line.length > 0) + .join("\n"), ); } + const openclawCommand = installResult.command; const requestedGatewayPort = parseOptionalPort(opts.gatewayPort) ?? DEFAULT_GATEWAY_PORT; const stateDir = resolveProfileStateDir(profile); - const configPath = path.join(stateDir, "config.json"); - const forceOnboard = Boolean(opts.forceOnboard); - const needsOnboard = forceOnboard || !existsSync(configPath); - - if (needsOnboard) { - const onboardArgv = [ - "openclaw", - "--profile", - profile, - "onboard", - "--install-daemon", - "--gateway-bind", - "loopback", - "--gateway-port", - String(requestedGatewayPort), - ]; - if (nonInteractive) { - onboardArgv.push("--non-interactive", "--accept-risk"); - } - if (opts.noOpen) { - onboardArgv.push("--skip-ui"); - } + const onboardArgv = [ + "--profile", + profile, + "onboard", + "--install-daemon", + "--gateway-bind", + "loopback", + "--gateway-port", + String(requestedGatewayPort), + ]; + if (nonInteractive) { + onboardArgv.push("--non-interactive", "--accept-risk"); + } + if (opts.noOpen) { + onboardArgv.push("--skip-ui"); + } + if (nonInteractive) { await runOpenClawOrThrow({ - argv: onboardArgv, + openclawCommand, + args: onboardArgv, + timeoutMs: 12 * 60_000, + errorMessage: "OpenClaw onboarding failed.", + }); + } else { + await runOpenClawInteractiveOrThrow({ + openclawCommand, + args: onboardArgv, timeoutMs: 12 * 60_000, errorMessage: "OpenClaw onboarding failed.", }); } - // Ensure gateway.mode=local so the gateway doesn't refuse to start. - // Must run after onboard (which creates the config file on first run). - await ensureGatewayModeLocal(profile); + const denchInstall = syncBundledDenchSkill(stateDir); + const workspaceSeed = seedWorkspaceFromAssets({ + workspaceDir: resolveBootstrapWorkspaceDir(stateDir), + packageRoot: resolveCliPackageRoot(), + }); - if (!needsOnboard) { - await runOpenClawOrThrow({ - argv: ["openclaw", "--profile", profile, "gateway", "install"], - timeoutMs: 2 * 60_000, - errorMessage: "Failed to install/verify gateway daemon.", - }); - await runOpenClawOrThrow({ - argv: ["openclaw", "--profile", profile, "gateway", "start"], - timeoutMs: 2 * 60_000, - errorMessage: "Failed to start gateway daemon.", - }); - } + // Ensure gateway.mode=local so the gateway never drifts to remote mode. + // Keep this post-onboard so we normalize any wizard defaults. + await ensureGatewayModeLocal(openclawCommand, profile); if (await shouldRunUpdate({ opts, runtime })) { await runOpenClawWithProgress({ - argv: ["openclaw", "update", "--yes"], + openclawCommand, + args: ["update", "--yes"], timeoutMs: 8 * 60_000, startMessage: "Checking for OpenClaw updates...", successMessage: "OpenClaw is up to date.", @@ -783,7 +1267,24 @@ export async function bootstrapCommand( }); } - const gatewayProbe = await probeGateway(profile); + let gatewayProbe = await probeGateway(openclawCommand, profile); + let gatewayAutoFix: GatewayAutoFixResult | undefined; + if (!gatewayProbe.ok) { + gatewayAutoFix = await attemptGatewayAutoFix({ + openclawCommand, + profile, + stateDir, + }); + gatewayProbe = gatewayAutoFix.finalProbe; + if (!gatewayProbe.ok && gatewayAutoFix.failureSummary) { + gatewayProbe = { + ...gatewayProbe, + detail: [gatewayProbe.detail, gatewayAutoFix.failureSummary] + .filter((value, index, self) => value && self.indexOf(value) === index) + .join(" | "), + }; + } + } const gatewayUrl = `ws://127.0.0.1:${requestedGatewayPort}`; const preferredWebPort = parseOptionalPort(opts.webPort) ?? DEFAULT_WEB_APP_PORT; @@ -805,12 +1306,66 @@ export async function bootstrapCommand( webReachable, rolloutStage, legacyFallbackEnabled, + stateDir, }); const shouldOpen = !opts.noOpen && !opts.json; const opened = shouldOpen ? await openUrl(webUrl) : false; if (!opts.json) { + if (installResult.installed) { + runtime.log(theme.muted("Installed global OpenClaw CLI via npm.")); + } + if (isProjectLocalOpenClawPath(installResult.shellCommandPath)) { + runtime.log( + theme.warn( + `\`openclaw\` currently resolves to a project-local binary (${installResult.shellCommandPath}).`, + ), + ); + runtime.log( + theme.muted( + `Bootstrap now uses the global binary (${openclawCommand}) to avoid repo-local drift.`, + ), + ); + } else if (!installResult.shellCommandPath && installResult.globalBinDir) { + runtime.log( + theme.warn("Global OpenClaw was installed, but `openclaw` is not on shell PATH."), + ); + runtime.log( + theme.muted( + `Add this to your shell profile, then open a new terminal: export PATH="${installResult.globalBinDir}:$PATH"`, + ), + ); + } + + runtime.log(theme.muted(`Dench skill ${denchInstall.mode}: ${denchInstall.targetDir}`)); + runtime.log(theme.muted(`Workspace seed: ${describeWorkspaceSeedResult(workspaceSeed)}`)); + if (gatewayAutoFix?.attempted) { + runtime.log( + theme.muted( + `Gateway auto-fix ${gatewayAutoFix.recovered ? "recovered connectivity" : "ran but gateway is still unhealthy"}.`, + ), + ); + for (const step of gatewayAutoFix.steps) { + runtime.log( + theme.muted( + ` ${step.ok ? "[ok]" : "[fail]"} ${step.name}${step.detail ? ` (${step.detail})` : ""}`, + ), + ); + } + if (!gatewayAutoFix.recovered && gatewayAutoFix.failureSummary) { + runtime.log(theme.error(`Likely gateway cause: ${gatewayAutoFix.failureSummary}`)); + } + if (!gatewayAutoFix.recovered && gatewayAutoFix.logExcerpts.length > 0) { + runtime.log(theme.muted("Recent gateway logs:")); + for (const excerpt of gatewayAutoFix.logExcerpts) { + runtime.log(theme.muted(` ${excerpt.path}`)); + for (const line of excerpt.excerpt.split(/\r?\n/)) { + runtime.log(theme.muted(` ${line}`)); + } + } + } + } logBootstrapChecklist(diagnostics, runtime); runtime.log(""); runtime.log(theme.heading("IronClaw ready")); @@ -835,12 +1390,22 @@ export async function bootstrapCommand( const summary: BootstrapSummary = { profile, - onboarded: needsOnboard, + onboarded: true, installedOpenClawCli: installResult.installed, openClawCliAvailable: installResult.available, openClawVersion: installResult.version, gatewayUrl, gatewayReachable: gatewayProbe.ok, + gatewayAutoFix: gatewayAutoFix + ? { + attempted: gatewayAutoFix.attempted, + recovered: gatewayAutoFix.recovered, + steps: gatewayAutoFix.steps, + failureSummary: gatewayAutoFix.failureSummary, + logExcerpts: gatewayAutoFix.logExcerpts, + } + : undefined, + workspaceSeed, webUrl, webReachable, webOpened: opened,