feat(cli): overhaul bootstrap-external with gateway auto-fix, robust CLI detection, and agent-auth check

This commit is contained in:
kumarabhirup 2026-03-02 18:31:15 -08:00
parent be246df30f
commit 0f057c0346
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167

View File

@ -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<SpawnResult> {
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<SpawnResult>((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<void> {
async function ensureGatewayModeLocal(openclawCommand: string, profile: string): Promise<void> {
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<void> {
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<SpawnResult> {
return await runCommandWithTimeout(argv, { timeoutMs });
async function runOpenClaw(
openclawCommand: string,
args: string[],
timeoutMs: number,
ioMode: "capture" | "inherit" = "capture",
): Promise<SpawnResult> {
return await runCommandWithTimeout([openclawCommand, ...args], { timeoutMs, ioMode });
}
async function runOpenClawOrThrow(params: {
argv: string[];
openclawCommand: string;
args: string[];
timeoutMs: number;
errorMessage: string;
}): Promise<SpawnResult> {
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<SpawnResult> {
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<SpawnResult>((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<string, unknown> | 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<string, unknown>) : 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<string, unknown>) : 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<string, { version?: string } | undefined>
| 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<string | undefined> {
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<string | undefined> {
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<OpenClawCliAvailability> {
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<GatewayAutoFixResult> {
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<boolean> {
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 <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 <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<string, unknown> | 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<string, unknown>;
}
} 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<string, unknown>).provider === provider &&
typeof (p as Record<string, unknown>).key === "string" &&
((p as Record<string, unknown>).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,