feat(cli): overhaul bootstrap-external with gateway auto-fix, robust CLI detection, and agent-auth check
This commit is contained in:
parent
be246df30f
commit
0f057c0346
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user