Make bootstrap deterministic across profile flag order and stale local runtime state so onboarding and health checks converge on the intended profile, gateway service, and web port.
1451 lines
44 KiB
TypeScript
1451 lines
44 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
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";
|
|
import { fileURLToPath } from "node:url";
|
|
import { confirm, isCancel, spinner } from "@clack/prompts";
|
|
import { isTruthyEnvValue } from "../infra/env.js";
|
|
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
|
import { theme } from "../terminal/theme.js";
|
|
import { isValidProfileName } from "./profile-utils.js";
|
|
import { applyCliProfileEnv } from "./profile.js";
|
|
import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js";
|
|
|
|
const DEFAULT_IRONCLAW_PROFILE = "ironclaw";
|
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
const DEFAULT_WEB_APP_PORT = 3100;
|
|
const WEB_APP_PROBE_ATTEMPTS = 20;
|
|
const WEB_APP_PROBE_DELAY_MS = 750;
|
|
const DEFAULT_BOOTSTRAP_ROLLOUT_STAGE = "default";
|
|
const DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL = "ai.openclaw.gateway";
|
|
|
|
type BootstrapRolloutStage = "internal" | "beta" | "default";
|
|
type BootstrapCheckStatus = "pass" | "warn" | "fail";
|
|
|
|
export type BootstrapCheck = {
|
|
id:
|
|
| "openclaw-cli"
|
|
| "profile"
|
|
| "gateway"
|
|
| "agent-auth"
|
|
| "web-ui"
|
|
| "state-isolation"
|
|
| "daemon-label"
|
|
| "rollout-stage"
|
|
| "cutover-gates";
|
|
status: BootstrapCheckStatus;
|
|
detail: string;
|
|
remediation?: string;
|
|
};
|
|
|
|
export type BootstrapDiagnostics = {
|
|
rolloutStage: BootstrapRolloutStage;
|
|
legacyFallbackEnabled: boolean;
|
|
checks: BootstrapCheck[];
|
|
hasFailures: boolean;
|
|
};
|
|
|
|
export type BootstrapOptions = {
|
|
profile?: string;
|
|
yes?: boolean;
|
|
nonInteractive?: boolean;
|
|
forceOnboard?: boolean;
|
|
skipUpdate?: boolean;
|
|
updateNow?: boolean;
|
|
noOpen?: boolean;
|
|
json?: boolean;
|
|
gatewayPort?: string | number;
|
|
webPort?: string | number;
|
|
};
|
|
|
|
type BootstrapSummary = {
|
|
profile: string;
|
|
onboarded: boolean;
|
|
installedOpenClawCli: boolean;
|
|
openClawCliAvailable: boolean;
|
|
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;
|
|
diagnostics: BootstrapDiagnostics;
|
|
};
|
|
|
|
type SpawnResult = {
|
|
stdout: string;
|
|
stderr: string;
|
|
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;
|
|
}
|
|
if (path.extname(command)) {
|
|
return command;
|
|
}
|
|
const normalized = path.basename(command).toLowerCase();
|
|
if (
|
|
normalized === "npm" ||
|
|
normalized === "pnpm" ||
|
|
normalized === "npx" ||
|
|
normalized === "yarn"
|
|
) {
|
|
return `${command}.cmd`;
|
|
}
|
|
return command;
|
|
}
|
|
|
|
async function runCommandWithTimeout(
|
|
argv: string[],
|
|
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,
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
child.kill("SIGKILL");
|
|
}, options.timeoutMs);
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
child.once("error", (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.once("close", (code) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({
|
|
code: typeof code === "number" ? code : 1,
|
|
stdout,
|
|
stderr,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseOptionalPort(value: string | number | undefined): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
const raw = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
if (!Number.isFinite(raw) || raw <= 0) {
|
|
return undefined;
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
async function sleep(ms: number) {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function normalizeBootstrapRolloutStage(raw: string | undefined): BootstrapRolloutStage {
|
|
const normalized = raw?.trim().toLowerCase();
|
|
if (normalized === "internal" || normalized === "beta" || normalized === "default") {
|
|
return normalized;
|
|
}
|
|
return DEFAULT_BOOTSTRAP_ROLLOUT_STAGE;
|
|
}
|
|
|
|
export function resolveBootstrapRolloutStage(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): BootstrapRolloutStage {
|
|
return normalizeBootstrapRolloutStage(
|
|
env.IRONCLAW_BOOTSTRAP_ROLLOUT ?? env.OPENCLAW_BOOTSTRAP_ROLLOUT,
|
|
);
|
|
}
|
|
|
|
export function isLegacyFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
return (
|
|
isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_LEGACY_FALLBACK) ||
|
|
isTruthyEnvValue(env.OPENCLAW_BOOTSTRAP_LEGACY_FALLBACK)
|
|
);
|
|
}
|
|
|
|
function normalizeVersionOutput(raw: string | undefined): string | undefined {
|
|
const first = raw
|
|
?.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
return first && first.length > 0 ? first : undefined;
|
|
}
|
|
|
|
function firstNonEmptyLine(...values: Array<string | undefined>): string | undefined {
|
|
for (const value of values) {
|
|
const first = value
|
|
?.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
if (first) {
|
|
return first;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveProfileStateDir(profile: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
const explicitStateDir = env.OPENCLAW_STATE_DIR?.trim();
|
|
if (explicitStateDir) {
|
|
return path.resolve(explicitStateDir);
|
|
}
|
|
const home = resolveRequiredHomeDir(env, os.homedir);
|
|
if (!profile || profile === "default") {
|
|
return path.join(home, ".openclaw");
|
|
}
|
|
return path.join(home, `.openclaw-${profile}`);
|
|
}
|
|
|
|
function resolveBootstrapProfile(
|
|
opts: BootstrapOptions,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
const explicitProfile = opts.profile?.trim() || env.OPENCLAW_PROFILE?.trim();
|
|
const profile = explicitProfile || DEFAULT_IRONCLAW_PROFILE;
|
|
if (!isValidProfileName(profile)) {
|
|
throw new Error('Invalid --profile (use letters, numbers, "_", "-" only)');
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
function resolveGatewayLaunchAgentLabel(profile: string): string {
|
|
const normalized = profile.trim().toLowerCase();
|
|
if (!normalized || normalized === "default") {
|
|
return DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL;
|
|
}
|
|
return `ai.openclaw.${normalized}`;
|
|
}
|
|
|
|
async function ensureGatewayModeLocal(openclawCommand: string, profile: string): Promise<void> {
|
|
const result = await runOpenClaw(
|
|
openclawCommand,
|
|
["--profile", profile, "config", "get", "gateway.mode"],
|
|
10_000,
|
|
);
|
|
const currentMode = result.stdout.trim();
|
|
if (currentMode === "local") {
|
|
return;
|
|
}
|
|
await runOpenClawOrThrow({
|
|
openclawCommand,
|
|
args: ["--profile", profile, "config", "set", "gateway.mode", "local"],
|
|
timeoutMs: 10_000,
|
|
errorMessage: "Failed to set gateway.mode=local.",
|
|
});
|
|
}
|
|
|
|
async function probeForWebApp(port: number): Promise<boolean> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 1_500);
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, {
|
|
method: "GET",
|
|
signal: controller.signal,
|
|
redirect: "manual",
|
|
});
|
|
if (response.status < 200 || response.status >= 400) {
|
|
return false;
|
|
}
|
|
const payload = (await response.json().catch(() => null)) as {
|
|
profiles?: unknown;
|
|
activeProfile?: unknown;
|
|
} | null;
|
|
return Boolean(
|
|
payload &&
|
|
typeof payload === "object" &&
|
|
Array.isArray(payload.profiles) &&
|
|
typeof payload.activeProfile === "string",
|
|
);
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function waitForWebApp(preferredPort: number): Promise<boolean> {
|
|
for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) {
|
|
if (await probeForWebApp(preferredPort)) {
|
|
return true;
|
|
}
|
|
await sleep(WEB_APP_PROBE_DELAY_MS);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveCliPackageRoot(): string {
|
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
for (let i = 0; i < 5; i++) {
|
|
if (existsSync(path.join(dir, "package.json"))) {
|
|
return dir;
|
|
}
|
|
dir = path.dirname(dir);
|
|
}
|
|
return process.cwd();
|
|
}
|
|
|
|
/**
|
|
* Spawn the pre-built standalone Next.js server as a detached background
|
|
* process if it isn't already running on the target port.
|
|
*/
|
|
function startWebAppIfNeeded(port: number, stateDir: string): void {
|
|
const pkgRoot = resolveCliPackageRoot();
|
|
const standaloneServer = path.join(pkgRoot, "apps/web/.next/standalone/apps/web/server.js");
|
|
if (!existsSync(standaloneServer)) {
|
|
return;
|
|
}
|
|
|
|
const logDir = path.join(stateDir, "logs");
|
|
mkdirSync(logDir, { recursive: true });
|
|
const outFd = openSync(path.join(logDir, "web-app.log"), "a");
|
|
const errFd = openSync(path.join(logDir, "web-app.err.log"), "a");
|
|
|
|
const child = spawn(process.execPath, [standaloneServer], {
|
|
cwd: path.dirname(standaloneServer),
|
|
detached: true,
|
|
stdio: ["ignore", outFd, errFd],
|
|
env: { ...process.env, PORT: String(port), HOSTNAME: "127.0.0.1" },
|
|
});
|
|
child.unref();
|
|
}
|
|
|
|
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: {
|
|
openclawCommand: string;
|
|
args: string[];
|
|
timeoutMs: number;
|
|
errorMessage: string;
|
|
}): Promise<SpawnResult> {
|
|
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;
|
|
}
|
|
const detail = firstNonEmptyLine(result.stderr, result.stdout);
|
|
throw new Error(detail ? `${params.errorMessage}\n${detail}` : params.errorMessage);
|
|
}
|
|
|
|
/**
|
|
* Runs an openclaw sub-command with a visible spinner that streams progress
|
|
* from the subprocess stdout/stderr into the spinner message.
|
|
*/
|
|
async function runOpenClawWithProgress(params: {
|
|
openclawCommand: string;
|
|
args: string[];
|
|
timeoutMs: number;
|
|
startMessage: string;
|
|
successMessage: string;
|
|
errorMessage: string;
|
|
}): Promise<SpawnResult> {
|
|
const s = spinner();
|
|
s.start(params.startMessage);
|
|
|
|
const result = await new Promise<SpawnResult>((resolve, reject) => {
|
|
const child = spawn(resolveCommandForPlatform(params.openclawCommand), params.args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (!settled) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, params.timeoutMs);
|
|
|
|
const updateSpinner = (chunk: string) => {
|
|
const line = chunk
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
.pop();
|
|
if (line) {
|
|
s.message(line.length > 72 ? `${line.slice(0, 69)}...` : line);
|
|
}
|
|
};
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
const text = String(chunk);
|
|
stdout += text;
|
|
updateSpinner(text);
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
const text = String(chunk);
|
|
stderr += text;
|
|
updateSpinner(text);
|
|
});
|
|
child.once("error", (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
child.once("close", (code) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ code: typeof code === "number" ? code : 1, stdout, stderr });
|
|
});
|
|
});
|
|
|
|
if (result.code === 0) {
|
|
s.stop(params.successMessage);
|
|
return result;
|
|
}
|
|
|
|
const detail = firstNonEmptyLine(result.stderr, result.stdout);
|
|
s.stop(detail ? `${params.errorMessage}: ${detail}` : params.errorMessage, result.code);
|
|
throw new Error(detail ? `${params.errorMessage}\n${detail}` : params.errorMessage);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (!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 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,
|
|
installed,
|
|
version,
|
|
command,
|
|
globalBinDir,
|
|
shellCommandPath,
|
|
};
|
|
}
|
|
|
|
async function probeGateway(
|
|
openclawCommand: string,
|
|
profile: string,
|
|
): Promise<{ ok: boolean; detail?: string }> {
|
|
const result = await runOpenClaw(
|
|
openclawCommand,
|
|
["--profile", profile, "health", "--json"],
|
|
12_000,
|
|
).catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
code: 1,
|
|
stdout: "",
|
|
stderr: message,
|
|
} as SpawnResult;
|
|
});
|
|
if (result.code === 0) {
|
|
return { ok: true };
|
|
}
|
|
return {
|
|
ok: false,
|
|
detail: firstNonEmptyLine(result.stderr, result.stdout),
|
|
};
|
|
}
|
|
|
|
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|device token mismatch|device signature invalid|device signature expired|device-signature|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 gateway stop",
|
|
args: ["--profile", params.profile, "gateway", "stop"],
|
|
timeoutMs: 90_000,
|
|
},
|
|
{
|
|
name: "openclaw doctor --fix",
|
|
args: ["--profile", params.profile, "doctor", "--fix"],
|
|
timeoutMs: 2 * 60_000,
|
|
},
|
|
{
|
|
name: "openclaw gateway install --force",
|
|
args: ["--profile", params.profile, "gateway", "install", "--force"],
|
|
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"
|
|
? ["open", url]
|
|
: process.platform === "win32"
|
|
? ["cmd", "/c", "start", "", url]
|
|
: ["xdg-open", url];
|
|
const result = await runCommandWithTimeout(argv, { timeoutMs: 5_000 }).catch(() => null);
|
|
return Boolean(result && result.code === 0);
|
|
}
|
|
|
|
function remediationForGatewayFailure(
|
|
detail: string | undefined,
|
|
port: number,
|
|
profile: string,
|
|
): string {
|
|
const normalized = detail?.toLowerCase() ?? "";
|
|
const isDeviceAuthMismatch =
|
|
normalized.includes("device token mismatch") ||
|
|
normalized.includes("device signature invalid") ||
|
|
normalized.includes("device signature expired") ||
|
|
normalized.includes("device-signature");
|
|
if (isDeviceAuthMismatch) {
|
|
return [
|
|
`Gateway device-auth mismatch detected. Re-run \`openclaw --profile ${profile} onboard --install-daemon --reset\`.`,
|
|
`Last resort (security downgrade): \`openclaw --profile ${profile} config set gateway.controlUi.dangerouslyDisableDeviceAuth true\`. Revert after recovery: \`openclaw --profile ${profile} config set gateway.controlUi.dangerouslyDisableDeviceAuth false\`.`,
|
|
].join(" ");
|
|
}
|
|
if (
|
|
normalized.includes("unauthorized") ||
|
|
normalized.includes("token") ||
|
|
normalized.includes("password")
|
|
) {
|
|
return `Gateway auth mismatch detected. Re-run \`openclaw --profile ${profile} onboard --install-daemon --reset\`.`;
|
|
}
|
|
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 --profile ${profile} doctor --fix\` and retry \`ironclaw bootstrap --profile ${profile} --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,
|
|
detail: string,
|
|
remediation?: string,
|
|
): BootstrapCheck {
|
|
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;
|
|
openClawVersion?: string;
|
|
gatewayPort: number;
|
|
gatewayUrl: string;
|
|
gatewayProbe: { ok: boolean; detail?: string };
|
|
webPort: number;
|
|
webReachable: boolean;
|
|
rolloutStage: BootstrapRolloutStage;
|
|
legacyFallbackEnabled: boolean;
|
|
stateDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): BootstrapDiagnostics {
|
|
const env = params.env ?? process.env;
|
|
const checks: BootstrapCheck[] = [];
|
|
|
|
if (params.openClawCliAvailable) {
|
|
checks.push(
|
|
createCheck(
|
|
"openclaw-cli",
|
|
"pass",
|
|
`OpenClaw CLI detected${params.openClawVersion ? ` (${params.openClawVersion})` : ""}.`,
|
|
),
|
|
);
|
|
} else {
|
|
checks.push(
|
|
createCheck(
|
|
"openclaw-cli",
|
|
"fail",
|
|
"OpenClaw CLI is missing.",
|
|
"Install OpenClaw globally once: `npm install -g openclaw`.",
|
|
),
|
|
);
|
|
}
|
|
|
|
if (params.profile === DEFAULT_IRONCLAW_PROFILE) {
|
|
checks.push(createCheck("profile", "pass", `Profile verified: ${params.profile}.`));
|
|
} else {
|
|
checks.push(
|
|
createCheck(
|
|
"profile",
|
|
"warn",
|
|
`Profile is set to '${params.profile}' (expected '${DEFAULT_IRONCLAW_PROFILE}' for side-by-side safety).`,
|
|
`Rerun with \`OPENCLAW_PROFILE=${DEFAULT_IRONCLAW_PROFILE}\` or pass \`--profile ${DEFAULT_IRONCLAW_PROFILE}\`.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (params.gatewayProbe.ok) {
|
|
checks.push(createCheck("gateway", "pass", `Gateway reachable at ${params.gatewayUrl}.`));
|
|
} else {
|
|
checks.push(
|
|
createCheck(
|
|
"gateway",
|
|
"fail",
|
|
`Gateway probe failed at ${params.gatewayUrl}${params.gatewayProbe.detail ? ` (${params.gatewayProbe.detail})` : ""}.`,
|
|
remediationForGatewayFailure(
|
|
params.gatewayProbe.detail,
|
|
params.gatewayPort,
|
|
params.profile,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
checks.push(
|
|
createCheck(
|
|
"web-ui",
|
|
"fail",
|
|
`Web UI is not reachable on port ${params.webPort}.`,
|
|
remediationForWebUiFailure(params.webPort),
|
|
),
|
|
);
|
|
}
|
|
|
|
const defaultStateDir = path.join(resolveRequiredHomeDir(env, os.homedir), ".openclaw");
|
|
const usesIsolatedStateDir =
|
|
params.profile === "default" || path.resolve(stateDir) !== path.resolve(defaultStateDir);
|
|
if (usesIsolatedStateDir) {
|
|
checks.push(createCheck("state-isolation", "pass", `Profile state dir: ${stateDir}.`));
|
|
} else {
|
|
checks.push(
|
|
createCheck(
|
|
"state-isolation",
|
|
"fail",
|
|
`Profile state dir overlaps default profile: ${stateDir}.`,
|
|
`Set \`OPENCLAW_PROFILE=${params.profile}\` (or \`OPENCLAW_STATE_DIR=~/.openclaw-${params.profile}\`) before bootstrap.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
const launchAgentLabel = resolveGatewayLaunchAgentLabel(params.profile);
|
|
const launchAgentIsIsolated =
|
|
params.profile === "default" || launchAgentLabel !== DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL;
|
|
if (launchAgentIsIsolated) {
|
|
checks.push(createCheck("daemon-label", "pass", `Gateway service label: ${launchAgentLabel}.`));
|
|
} else {
|
|
checks.push(
|
|
createCheck(
|
|
"daemon-label",
|
|
"fail",
|
|
`Gateway service label is shared with default profile (${launchAgentLabel}).`,
|
|
"Use a non-default profile to avoid LaunchAgent/service collisions.",
|
|
),
|
|
);
|
|
}
|
|
|
|
checks.push(
|
|
createCheck(
|
|
"rollout-stage",
|
|
params.rolloutStage === "default" ? "pass" : "warn",
|
|
`Bootstrap rollout stage: ${params.rolloutStage}${params.legacyFallbackEnabled ? " (legacy fallback enabled)" : ""}.`,
|
|
params.rolloutStage === "beta"
|
|
? "Enable beta cutover by setting IRONCLAW_BOOTSTRAP_BETA_OPT_IN=1."
|
|
: undefined,
|
|
),
|
|
);
|
|
|
|
const migrationSuiteOk = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK);
|
|
const onboardingE2EOk = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK);
|
|
const enforceCutoverGates = isTruthyEnvValue(env.IRONCLAW_BOOTSTRAP_ENFORCE_SAFETY_GATES);
|
|
const cutoverGatePassed = migrationSuiteOk && onboardingE2EOk;
|
|
checks.push(
|
|
createCheck(
|
|
"cutover-gates",
|
|
cutoverGatePassed ? "pass" : enforceCutoverGates ? "fail" : "warn",
|
|
`Cutover gate: migrationSuite=${migrationSuiteOk ? "pass" : "missing"}, onboardingE2E=${onboardingE2EOk ? "pass" : "missing"}.`,
|
|
cutoverGatePassed
|
|
? undefined
|
|
: "Run migration contracts + onboarding E2E and set IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK=1 and IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK=1 before full cutover.",
|
|
),
|
|
);
|
|
|
|
return {
|
|
rolloutStage: params.rolloutStage,
|
|
legacyFallbackEnabled: params.legacyFallbackEnabled,
|
|
checks,
|
|
hasFailures: checks.some((check) => check.status === "fail"),
|
|
};
|
|
}
|
|
|
|
function formatCheckStatus(status: BootstrapCheckStatus): string {
|
|
if (status === "pass") {
|
|
return theme.success("[ok]");
|
|
}
|
|
if (status === "warn") {
|
|
return theme.warn("[warn]");
|
|
}
|
|
return theme.error("[fail]");
|
|
}
|
|
|
|
function logBootstrapChecklist(diagnostics: BootstrapDiagnostics, runtime: RuntimeEnv) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Bootstrap checklist"));
|
|
for (const check of diagnostics.checks) {
|
|
runtime.log(`${formatCheckStatus(check.status)} ${check.detail}`);
|
|
if (check.status !== "pass" && check.remediation) {
|
|
runtime.log(theme.muted(` remediation: ${check.remediation}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function shouldRunUpdate(params: {
|
|
opts: BootstrapOptions;
|
|
runtime: RuntimeEnv;
|
|
}): Promise<boolean> {
|
|
if (params.opts.updateNow) {
|
|
return true;
|
|
}
|
|
if (
|
|
params.opts.skipUpdate ||
|
|
params.opts.nonInteractive ||
|
|
params.opts.json ||
|
|
!process.stdin.isTTY
|
|
) {
|
|
return false;
|
|
}
|
|
const decision = await confirm({
|
|
message: stylePromptMessage("Check and install OpenClaw updates now?"),
|
|
initialValue: false,
|
|
});
|
|
if (isCancel(decision)) {
|
|
params.runtime.log(theme.muted("Update check skipped."));
|
|
return false;
|
|
}
|
|
return Boolean(decision);
|
|
}
|
|
|
|
export async function bootstrapCommand(
|
|
opts: BootstrapOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
): Promise<BootstrapSummary> {
|
|
const nonInteractive = Boolean(opts.nonInteractive || opts.json);
|
|
const profile = resolveBootstrapProfile(opts);
|
|
const rolloutStage = resolveBootstrapRolloutStage();
|
|
const legacyFallbackEnabled = isLegacyFallbackEnabled();
|
|
applyCliProfileEnv({ profile });
|
|
|
|
const installResult = await ensureOpenClawCliAvailable();
|
|
if (!installResult.available) {
|
|
throw new Error(
|
|
[
|
|
"OpenClaw CLI is required but unavailable.",
|
|
"Install it with: npm install -g openclaw",
|
|
installResult.globalBinDir
|
|
? `Expected global binary directory: ${installResult.globalBinDir}`
|
|
: "",
|
|
]
|
|
.filter((line) => line.length > 0)
|
|
.join("\n"),
|
|
);
|
|
}
|
|
const openclawCommand = installResult.command;
|
|
|
|
if (await shouldRunUpdate({ opts, runtime })) {
|
|
await runOpenClawWithProgress({
|
|
openclawCommand,
|
|
args: ["update", "--yes"],
|
|
timeoutMs: 8 * 60_000,
|
|
startMessage: "Checking for OpenClaw updates...",
|
|
successMessage: "OpenClaw is up to date.",
|
|
errorMessage: "OpenClaw update failed",
|
|
});
|
|
}
|
|
|
|
const requestedGatewayPort = parseOptionalPort(opts.gatewayPort) ?? DEFAULT_GATEWAY_PORT;
|
|
const stateDir = resolveProfileStateDir(profile);
|
|
const onboardArgv = [
|
|
"--profile",
|
|
profile,
|
|
"onboard",
|
|
"--install-daemon",
|
|
"--gateway-bind",
|
|
"loopback",
|
|
"--gateway-port",
|
|
String(requestedGatewayPort),
|
|
];
|
|
if (opts.forceOnboard) {
|
|
onboardArgv.push("--reset");
|
|
}
|
|
if (nonInteractive) {
|
|
onboardArgv.push("--non-interactive", "--accept-risk");
|
|
}
|
|
if (opts.noOpen) {
|
|
onboardArgv.push("--skip-ui");
|
|
}
|
|
if (nonInteractive) {
|
|
await runOpenClawOrThrow({
|
|
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.",
|
|
});
|
|
}
|
|
|
|
const denchInstall = syncBundledDenchSkill(stateDir);
|
|
const workspaceSeed = seedWorkspaceFromAssets({
|
|
workspaceDir: resolveBootstrapWorkspaceDir(stateDir),
|
|
packageRoot: resolveCliPackageRoot(),
|
|
});
|
|
|
|
// 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);
|
|
|
|
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;
|
|
|
|
if (!(await probeForWebApp(preferredWebPort))) {
|
|
startWebAppIfNeeded(preferredWebPort, stateDir);
|
|
}
|
|
|
|
const webReachable = await waitForWebApp(preferredWebPort);
|
|
const webUrl = `http://localhost:${preferredWebPort}`;
|
|
const diagnostics = buildBootstrapDiagnostics({
|
|
profile,
|
|
openClawCliAvailable: installResult.available,
|
|
openClawVersion: installResult.version,
|
|
gatewayPort: requestedGatewayPort,
|
|
gatewayUrl,
|
|
gatewayProbe,
|
|
webPort: preferredWebPort,
|
|
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"));
|
|
runtime.log(`Profile: ${profile}`);
|
|
runtime.log(`OpenClaw CLI: ${installResult.version ?? "detected"}`);
|
|
runtime.log(`Gateway: ${gatewayProbe.ok ? "reachable" : "check failed"}`);
|
|
runtime.log(`Web UI: ${webUrl}`);
|
|
runtime.log(
|
|
`Rollout stage: ${rolloutStage}${legacyFallbackEnabled ? " (legacy fallback enabled)" : ""}`,
|
|
);
|
|
if (!opened && shouldOpen) {
|
|
runtime.log(theme.muted("Browser open failed; copy/paste the URL above."));
|
|
}
|
|
if (diagnostics.hasFailures) {
|
|
runtime.log(
|
|
theme.warn(
|
|
"Bootstrap completed with failing checks. Address remediation items above before full cutover.",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const summary: BootstrapSummary = {
|
|
profile,
|
|
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,
|
|
diagnostics,
|
|
};
|
|
if (opts.json) {
|
|
runtime.log(JSON.stringify(summary, null, 2));
|
|
}
|
|
return summary;
|
|
}
|