fix(cli): harden bootstrap profile and recovery paths

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.
This commit is contained in:
kumarabhirup 2026-03-02 22:10:46 -08:00
parent a9520572be
commit 37400badc2
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
7 changed files with 418 additions and 54 deletions

View File

@ -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:

View File

@ -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

View File

@ -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<typeof import("node:child_process")>();
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<T>(isTTY: boolean, fn: () => Promise<T>): Promise<T> {
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<typeof vi.fn>;
let fetchBehavior: (url: string) => Promise<Response>;
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 });

View File

@ -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),

View File

@ -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<boolean> {
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<boolean> {
}
}
async function detectRunningWebAppPort(preferredPort: number): Promise<number> {
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<number> {
async function waitForWebApp(preferredPort: number): Promise<boolean> {
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<boolean> {
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 <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<BootstrapSummary> {
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,

View File

@ -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<string, string | undefined> = {};
const bootstrapLocalEnv: Record<string, string | undefined> = {};
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", () => {

View File

@ -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 <name>",
"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 <port>", "Gateway port override for first-run onboarding")
.option("--web-port <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),