diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index addf7597b9f..4f20ec2242a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: include: - runtime: node command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: bootstrap + command: pnpm vitest run --config src/cli/vitest.config.ts src/cli/profile.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts - runtime: bun command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts steps: diff --git a/src/cli/TESTING_EDGE_CASE_MATRIX.md b/src/cli/TESTING_EDGE_CASE_MATRIX.md index 1f868faceef..d37974dfd20 100644 --- a/src/cli/TESTING_EDGE_CASE_MATRIX.md +++ b/src/cli/TESTING_EDGE_CASE_MATRIX.md @@ -29,6 +29,7 @@ argument/env handling are treated as contract. | `profile-utils.ts` | profile name normalization | Only valid profile names are accepted; normalization is idempotent | `src/cli/profile-utils.test.ts` (new) | | `profile.ts` | `--dev` + `--profile` conflict | Conflict is rejected with non-zero outcome and actionable error text | `src/cli/profile.test.ts` (new) | | `profile.ts` | explicit profile propagation | Parsed profile and env output are stable regardless of option ordering | `src/cli/profile.test.ts` (new) | +| `profile.ts` | root vs command-local bootstrap profile flag | `ironclaw --profile X bootstrap` and `ironclaw bootstrap --profile X` resolve to identical profile env | `src/cli/profile.test.ts` (existing, expand) | | `windows-argv.ts` | control chars and duplicate exec path | Normalization removes terminal control noise while preserving args | `src/cli/windows-argv.test.ts` (new) | | `windows-argv.ts` | quoted executable path stripping | Windows executable wrappers are normalized without dropping real args | `src/cli/windows-argv.test.ts` (new) | | `respawn-policy.ts` | help/version short-circuit | Help/version always bypass respawn behavior | `src/cli/respawn-policy.test.ts` (new) | @@ -37,6 +38,8 @@ argument/env handling are treated as contract. | `cli-utils.ts` | runtime command failure path | Command failures return deterministic non-zero exit behavior | `src/cli/cli-utils.test.ts` (new) | | `bootstrap-external.ts` | auth profile mismatch/missing | Missing or mismatched provider auth fails with remediation | `src/cli/bootstrap-external.test.ts` (existing) | | `bootstrap-external.ts` | onboarding/gateway auto-fix workflow | Bootstrap command executes expected fallback sequence and reports recovery outcome | `src/cli/bootstrap-external.bootstrap-command.test.ts` (existing) | +| `bootstrap-external.ts` | device signature/token mismatch remediation | Device-auth failures provide reset-first guidance + break-glass toggle with explicit revert | `src/cli/bootstrap-external.test.ts` (existing, expand) | +| `bootstrap-external.ts` | web UI port ownership and deterministic bootstrap port selection | Bootstrap never silently drifts to sibling web ports and keeps expected UI URL stable | `src/cli/bootstrap-external.bootstrap-command.test.ts` (expand) | ## Exit/Output Contract Checks diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 80fb7082b2e..b63cae3ca3b 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -7,6 +7,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { bootstrapCommand } from "./bootstrap-external.js"; +const promptMocks = vi.hoisted(() => { + const cancelSignal = Symbol("clack-cancel"); + return { + cancelSignal, + confirmDecision: false as boolean | symbol, + confirm: vi.fn(async () => false as boolean | symbol), + isCancel: vi.fn((value: unknown) => value === cancelSignal), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), + }; +}); + +vi.mock("@clack/prompts", () => ({ + confirm: promptMocks.confirm, + isCancel: promptMocks.isCancel, + spinner: promptMocks.spinner, +})); + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { @@ -21,6 +42,18 @@ type SpawnCall = { options?: { stdio?: unknown }; }; +function createWebProfilesResponse(params?: { + status?: number; + payload?: { profiles?: unknown[]; activeProfile?: string }; +}): Response { + const status = params?.status ?? 200; + const payload = params?.payload ?? { profiles: [], activeProfile: "ironclaw" }; + return { + status, + json: async () => payload, + } as unknown as Response; +} + function createTempStateDir(): string { const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const dir = path.join(os.tmpdir(), `ironclaw-bootstrap-${suffix}`); @@ -87,11 +120,27 @@ function createMockChild(params: { return child; } +async function withForcedStdinTty(isTTY: boolean, fn: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: isTTY }); + try { + return await fn(); + } finally { + if (descriptor) { + Object.defineProperty(process.stdin, "isTTY", descriptor); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + } +} + describe("bootstrapCommand always-onboard behavior", () => { const originalEnv = { ...process.env }; const spawnMock = vi.mocked(spawn); let stateDir = ""; let spawnCalls: SpawnCall[] = []; + let fetchMock: ReturnType; + let fetchBehavior: (url: string) => Promise; let forceGlobalMissing = false; let globalDetectCount = 0; let healthFailuresBeforeSuccess = 0; @@ -113,6 +162,12 @@ describe("bootstrapCommand always-onboard behavior", () => { OPENCLAW_STATE_DIR: stateDir, VITEST: "true", }; + promptMocks.confirmDecision = false; + promptMocks.confirm.mockReset(); + promptMocks.confirm.mockImplementation(async () => promptMocks.confirmDecision); + promptMocks.isCancel.mockReset(); + promptMocks.isCancel.mockImplementation((value: unknown) => value === promptMocks.cancelSignal); + promptMocks.spinner.mockClear(); spawnMock.mockImplementation((command, args = [], options) => { const commandString = String(command); @@ -174,10 +229,29 @@ describe("bootstrapCommand always-onboard behavior", () => { return createMockChild({ code: 0, stdout: "ok\n" }) as never; }); - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ status: 200 }) as unknown as Response), - ); + fetchBehavior = async (url: string) => { + if (url.includes("/api/profiles")) { + return createWebProfilesResponse(); + } + return createWebProfilesResponse({ status: 404, payload: {} }); + }; + fetchMock = vi.fn(async (input: unknown) => { + let url = ""; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else if (input && typeof input === "object" && "url" in input) { + const requestUrl = (input as { url?: unknown }).url; + if (typeof requestUrl === "string") { + url = requestUrl; + } else if (requestUrl instanceof URL) { + url = requestUrl.toString(); + } + } + return await fetchBehavior(url); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); }); afterEach(() => { @@ -222,6 +296,144 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(summary.onboarded).toBe(true); }); + it("accepts bootstrap --profile and propagates it to onboard subprocesses", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + process.env.OPENCLAW_PROFILE = "ironclaw"; + + const summary = await bootstrapCommand( + { + profile: "team-a", + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const onboardCall = spawnCalls.find( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCall?.args).toEqual(expect.arrayContaining(["--profile", "team-a"])); + expect(summary.profile).toBe("team-a"); + }); + + it("adds --reset to onboarding args when --force-onboard is requested", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + forceOnboard: true, + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + const onboardCall = spawnCalls.find( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + expect(onboardCall?.args).toContain("--reset"); + }); + + it("runs update before onboarding when --update-now is set", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + updateNow: true, + }, + runtime, + ); + + const updateIndex = spawnCalls.findIndex( + (call) => + call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"), + ); + const onboardIndex = spawnCalls.findIndex( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + + expect(updateIndex).toBeGreaterThan(-1); + expect(onboardIndex).toBeGreaterThan(-1); + expect(updateIndex).toBeLessThan(onboardIndex); + }); + + it("runs update before onboarding when interactive prompt is accepted", async () => { + promptMocks.confirmDecision = true; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await withForcedStdinTty(true, async () => { + await bootstrapCommand( + { + noOpen: true, + }, + runtime, + ); + }); + + expect(promptMocks.confirm).toHaveBeenCalledTimes(1); + const updateIndex = spawnCalls.findIndex( + (call) => + call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"), + ); + const onboardIndex = spawnCalls.findIndex( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + + expect(updateIndex).toBeGreaterThan(-1); + expect(onboardIndex).toBeGreaterThan(-1); + expect(updateIndex).toBeLessThan(onboardIndex); + }); + + it("skips update when interactive prompt is declined", async () => { + promptMocks.confirmDecision = false; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await withForcedStdinTty(true, async () => { + await bootstrapCommand( + { + noOpen: true, + }, + runtime, + ); + }); + + expect(promptMocks.confirm).toHaveBeenCalledTimes(1); + const updateCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"), + ); + const onboardCalls = spawnCalls.filter( + (call) => call.command === "openclaw" && call.args.includes("onboard"), + ); + + expect(updateCalled).toBe(false); + expect(onboardCalls).toHaveLength(1); + }); + it("seeds workspace.duckdb on bootstrap when missing", async () => { const runtime: RuntimeEnv = { log: vi.fn(), @@ -508,11 +720,16 @@ describe("bootstrapCommand always-onboard behavior", () => { (call) => call.command === "openclaw" && call.args.includes("doctor") && call.args.includes("--fix"), ); + const gatewayStopCalled = spawnCalls.some( + (call) => + call.command === "openclaw" && call.args.includes("gateway") && call.args.includes("stop"), + ); const gatewayInstallCalled = spawnCalls.some( (call) => call.command === "openclaw" && call.args.includes("gateway") && - call.args.includes("install"), + call.args.includes("install") && + call.args.includes("--force"), ); const gatewayStartCalled = spawnCalls.some( (call) => @@ -520,6 +737,7 @@ describe("bootstrapCommand always-onboard behavior", () => { ); expect(doctorFixCalled).toBe(true); + expect(gatewayStopCalled).toBe(true); expect(gatewayInstallCalled).toBe(true); expect(gatewayStartCalled).toBe(true); expect(summary.gatewayReachable).toBe(true); @@ -527,6 +745,48 @@ describe("bootstrapCommand always-onboard behavior", () => { expect(summary.gatewayAutoFix?.recovered).toBe(true); }); + it("keeps preferred web port and does not probe sibling ports", async () => { + let preferredPortChecks = 0; + fetchBehavior = async (url: string) => { + if (url.includes("127.0.0.1:3100/api/profiles")) { + preferredPortChecks += 1; + if (preferredPortChecks <= 2) { + return createWebProfilesResponse({ status: 503, payload: {} }); + } + return createWebProfilesResponse({ + status: 200, + payload: { profiles: [], activeProfile: "ironclaw" }, + }); + } + if (url.includes("127.0.0.1:3101/api/profiles")) { + return createWebProfilesResponse({ + status: 200, + payload: { profiles: [{ id: "stale" }], activeProfile: "stale" }, + }); + } + return createWebProfilesResponse({ status: 404, payload: {} }); + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const summary = await bootstrapCommand( + { + nonInteractive: true, + noOpen: true, + skipUpdate: true, + }, + runtime, + ); + + expect(summary.webUrl).toBe("http://localhost:3100"); + expect(fetchMock.mock.calls.some((call) => String(call[0] ?? "").includes(":3101/"))).toBe( + false, + ); + }); + it("prints likely gateway cause with log excerpt when autofix cannot recover", async () => { alwaysHealthFail = true; mkdirSync(path.join(stateDir, "logs"), { recursive: true }); diff --git a/src/cli/bootstrap-external.test.ts b/src/cli/bootstrap-external.test.ts index ac9a19b3096..157e294ba58 100644 --- a/src/cli/bootstrap-external.test.ts +++ b/src/cli/bootstrap-external.test.ts @@ -150,9 +150,26 @@ describe("bootstrap-external diagnostics", () => { const gateway = getCheck(diagnostics, "gateway"); expect(gateway.status).toBe("fail"); expect(String(gateway.remediation)).toContain("onboard"); + expect(String(gateway.remediation)).not.toContain("dangerouslyDisableDeviceAuth"); expect(diagnostics.hasFailures).toBe(true); }); + it("includes break-glass guidance only for device signature/token mismatch failures", () => { + const diagnostics = buildBootstrapDiagnostics({ + ...baseParams(stateDir), + gatewayProbe: { + ok: false as const, + detail: "gateway connect failed: device signature invalid", + }, + }); + + const gateway = getCheck(diagnostics, "gateway"); + expect(gateway.status).toBe("fail"); + expect(String(gateway.remediation)).toContain("dangerouslyDisableDeviceAuth true"); + expect(String(gateway.remediation)).toContain("dangerouslyDisableDeviceAuth false"); + expect(String(gateway.remediation)).toContain("--profile ironclaw"); + }); + it("marks rollout-stage as warning for beta and includes opt-in guidance", () => { const diagnostics = buildBootstrapDiagnostics({ ...baseParams(stateDir), diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index 995e150a144..2ea270af885 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -10,6 +10,7 @@ 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"; @@ -48,6 +49,7 @@ export type BootstrapDiagnostics = { }; export type BootstrapOptions = { + profile?: string; yes?: boolean; nonInteractive?: boolean; forceOnboard?: boolean; @@ -266,6 +268,18 @@ function resolveProfileStateDir(profile: string, env: NodeJS.ProcessEnv = proces 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") { @@ -296,12 +310,24 @@ async function probeForWebApp(port: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1_500); try { - const response = await fetch(`http://127.0.0.1:${port}`, { + const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, { method: "GET", signal: controller.signal, redirect: "manual", }); - return response.status < 500; + 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 { @@ -309,31 +335,14 @@ async function probeForWebApp(port: number): Promise { } } -async function detectRunningWebAppPort(preferredPort: number): Promise { - if (await probeForWebApp(preferredPort)) { - return preferredPort; - } - for (let offset = 1; offset <= 10; offset += 1) { - const candidate = preferredPort + offset; - if (candidate > 65535) { - break; - } - if (await probeForWebApp(candidate)) { - return candidate; - } - } - return preferredPort; -} - -async function waitForWebAppPort(preferredPort: number): Promise { +async function waitForWebApp(preferredPort: number): Promise { for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) { - const port = await detectRunningWebAppPort(preferredPort); - if (await probeForWebApp(port)) { - return port; + if (await probeForWebApp(preferredPort)) { + return true; } await sleep(WEB_APP_PROBE_DELAY_MS); } - return preferredPort; + return false; } function resolveCliPackageRoot(): string { @@ -706,7 +715,7 @@ function deriveGatewayFailureSummary( ): 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; + /(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; @@ -725,14 +734,19 @@ async function attemptGatewayAutoFix(params: { 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", - args: ["--profile", params.profile, "gateway", "install"], + name: "openclaw gateway install --force", + args: ["--profile", params.profile, "gateway", "install", "--force"], timeoutMs: 2 * 60_000, }, { @@ -792,22 +806,34 @@ async function openUrl(url: string): Promise { return Boolean(result && result.code === 0); } -function remediationForGatewayFailure(detail: string | undefined, port: number): string { +function remediationForGatewayFailure( + detail: string | undefined, + port: number, + profile: string, +): string { const normalized = detail?.toLowerCase() ?? ""; - if (normalized.includes("device token mismatch")) { - return "Clear stale device auth and rerun: `openclaw --profile ironclaw onboard --install-daemon`."; + 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 ironclaw onboard --install-daemon`."; + 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 \`.`; } - return "Run `openclaw --profile ironclaw doctor --fix` and retry `ironclaw bootstrap --force-onboard`."; + return `Run \`openclaw --profile ${profile} doctor --fix\` and retry \`ironclaw bootstrap --profile ${profile} --force-onboard\`.`; } function remediationForWebUiFailure(port: number): string { @@ -1039,7 +1065,11 @@ export function buildBootstrapDiagnostics(params: { "gateway", "fail", `Gateway probe failed at ${params.gatewayUrl}${params.gatewayProbe.detail ? ` (${params.gatewayProbe.detail})` : ""}.`, - remediationForGatewayFailure(params.gatewayProbe.detail, params.gatewayPort), + remediationForGatewayFailure( + params.gatewayProbe.detail, + params.gatewayPort, + params.profile, + ), ), ); } @@ -1191,7 +1221,7 @@ export async function bootstrapCommand( runtime: RuntimeEnv = defaultRuntime, ): Promise { const nonInteractive = Boolean(opts.nonInteractive || opts.json); - const profile = process.env.OPENCLAW_PROFILE?.trim() || DEFAULT_IRONCLAW_PROFILE; + const profile = resolveBootstrapProfile(opts); const rolloutStage = resolveBootstrapRolloutStage(); const legacyFallbackEnabled = isLegacyFallbackEnabled(); applyCliProfileEnv({ profile }); @@ -1212,6 +1242,17 @@ export async function bootstrapCommand( } 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 = [ @@ -1224,6 +1265,9 @@ export async function bootstrapCommand( "--gateway-port", String(requestedGatewayPort), ]; + if (opts.forceOnboard) { + onboardArgv.push("--reset"); + } if (nonInteractive) { onboardArgv.push("--non-interactive", "--accept-risk"); } @@ -1256,17 +1300,6 @@ export async function bootstrapCommand( // Keep this post-onboard so we normalize any wizard defaults. await ensureGatewayModeLocal(openclawCommand, profile); - 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", - }); - } - let gatewayProbe = await probeGateway(openclawCommand, profile); let gatewayAutoFix: GatewayAutoFixResult | undefined; if (!gatewayProbe.ok) { @@ -1292,9 +1325,8 @@ export async function bootstrapCommand( startWebAppIfNeeded(preferredWebPort, stateDir); } - const runningWebPort = await waitForWebAppPort(preferredWebPort); - const webUrl = `http://localhost:${runningWebPort}`; - const webReachable = await probeForWebApp(runningWebPort); + const webReachable = await waitForWebApp(preferredWebPort); + const webUrl = `http://localhost:${preferredWebPort}`; const diagnostics = buildBootstrapDiagnostics({ profile, openClawCliAvailable: installResult.available, @@ -1302,7 +1334,7 @@ export async function bootstrapCommand( gatewayPort: requestedGatewayPort, gatewayUrl, gatewayProbe, - webPort: runningWebPort, + webPort: preferredWebPort, webReachable, rolloutStage, legacyFallbackEnabled, diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 87b59780dc8..2fb098d1c5e 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -48,6 +48,51 @@ describe("parseCliProfileArgs", () => { argv: ["node", "ironclaw", "chat", "--profile", "dev"], }); }); + + it("produces equivalent profile env for root and bootstrap-local profile forms", () => { + const rootProfile = parseCliProfileArgs([ + "node", + "ironclaw", + "--profile", + "team-a", + "bootstrap", + ]); + const bootstrapLocalProfile = parseCliProfileArgs([ + "node", + "ironclaw", + "bootstrap", + "--profile", + "team-a", + ]); + + expect(rootProfile).toEqual({ + ok: true, + profile: "team-a", + argv: ["node", "ironclaw", "bootstrap"], + }); + expect(bootstrapLocalProfile).toEqual({ + ok: true, + profile: null, + argv: ["node", "ironclaw", "bootstrap", "--profile", "team-a"], + }); + + const rootEnv: Record = {}; + const bootstrapLocalEnv: Record = {}; + applyCliProfileEnv({ + profile: "team-a", + env: rootEnv, + homedir: () => "/tmp/home", + }); + applyCliProfileEnv({ + profile: "team-a", + env: bootstrapLocalEnv, + homedir: () => "/tmp/home", + }); + + expect(rootEnv.OPENCLAW_PROFILE).toBe(bootstrapLocalEnv.OPENCLAW_PROFILE); + expect(rootEnv.OPENCLAW_STATE_DIR).toBe(bootstrapLocalEnv.OPENCLAW_STATE_DIR); + expect(rootEnv.OPENCLAW_CONFIG_PATH).toBe(bootstrapLocalEnv.OPENCLAW_CONFIG_PATH); + }); }); describe("applyCliProfileEnv", () => { diff --git a/src/cli/program/register.bootstrap.ts b/src/cli/program/register.bootstrap.ts index 413bdb0ade5..e08c84c7cf9 100644 --- a/src/cli/program/register.bootstrap.ts +++ b/src/cli/program/register.bootstrap.ts @@ -9,11 +9,15 @@ export function registerBootstrapCommand(program: Command) { program .command("bootstrap") .description("Bootstrap IronClaw on top of OpenClaw and open the web UI") + .option( + "--profile ", + "Use this profile for bootstrap subprocesses (same as root --profile)", + ) .option("--force-onboard", "Run onboarding even if config already exists", false) .option("--non-interactive", "Skip prompts where possible", false) .option("--yes", "Auto-approve install prompts", false) .option("--skip-update", "Skip update prompt/check", false) - .option("--update-now", "Run OpenClaw update immediately after bootstrap", false) + .option("--update-now", "Run OpenClaw update before onboarding", false) .option("--gateway-port ", "Gateway port override for first-run onboarding") .option("--web-port ", "Preferred web UI port (default: 3100)") .option("--no-open", "Do not open the browser automatically", false) @@ -26,6 +30,7 @@ export function registerBootstrapCommand(program: Command) { .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { await bootstrapCommand({ + profile: opts.profile as string | undefined, forceOnboard: Boolean(opts.forceOnboard), nonInteractive: Boolean(opts.nonInteractive), yes: Boolean(opts.yes),