Files
openclaw/src/commands/onboard.test.ts
T
Shakker b8f5aa8ec8 fix: keep skip-ui onboarding on guided flow (#118737)
Keep `openclaw onboard --skip-ui` and `openclaw setup --skip-ui` on guided onboarding while suppressing browser and terminal handoffs.

Refs #118388 and #118038.
Prepared head SHA: 4e284c4523
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-08-03 18:03:01 +01:00

997 lines
31 KiB
TypeScript

// Onboard command tests cover guided setup entrypoints, setup aliases, and CLI messaging.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { setupWizardCommand } from "./onboard.js";
type ConfigSnapshotStub = {
exists: boolean;
valid: boolean;
config: OpenClawConfig;
sourceConfig?: OpenClawConfig;
readError?: { code: string | null };
};
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
>[0];
const mocks = vi.hoisted(() => ({
runInteractiveSetup: vi.fn(async () => {}),
runGuidedOnboarding: vi.fn(async () => {}),
runNonInteractiveSetup: vi.fn(async () => {}),
hasInteractiveOnboardingTty: vi.fn(() => true),
resolvePluginProviders: vi.fn((): ProviderPlugin[] => [
{
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "setup-token",
label: "Setup token",
kind: "token",
wizard: { choiceId: "setup-token" },
run: vi.fn(),
runNonInteractive: vi.fn(),
validateNonInteractive: vi.fn(
async (ctx: ProviderAuthMethodNonInteractiveValidationContext) => {
if (ctx.opts.tokenExpiresIn === "nope") {
ctx.runtime.error("Invalid --token-expires-in: invalid duration");
ctx.runtime.exit(1);
return false;
}
return Boolean(ctx.opts.token);
},
),
},
{
id: "api-key",
label: "API key",
kind: "api_key",
wizard: { choiceId: "apiKey" },
run: vi.fn(),
runNonInteractive: vi.fn(),
validateNonInteractive: vi.fn(
async (ctx: ProviderAuthMethodNonInteractiveValidationContext) =>
Boolean(
await ctx.resolveApiKey({
provider: "anthropic",
flagValue:
typeof ctx.opts.anthropicApiKey === "string"
? ctx.opts.anthropicApiKey
: undefined,
flagName: "--anthropic-api-key",
envVar: "ANTHROPIC_API_KEY",
}),
),
),
},
],
},
]),
readConfigFileSnapshot: vi.fn<() => Promise<ConfigSnapshotStub>>(async () => ({
exists: false,
valid: false,
config: {},
})),
handleReset: vi.fn(async () => {}),
}));
vi.mock("./onboard-interactive.js", () => ({
runInteractiveSetup: mocks.runInteractiveSetup,
}));
vi.mock("./onboard-guided.js", () => ({
runGuidedOnboarding: mocks.runGuidedOnboarding,
}));
vi.mock("./onboard-non-interactive.js", () => ({
runNonInteractiveSetup: mocks.runNonInteractiveSetup,
}));
vi.mock("./onboard-interactive-runner.js", () => ({
hasInteractiveOnboardingTty: mocks.hasInteractiveOnboardingTty,
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
resolveGatewayPort: () => 18_789,
}));
vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: mocks.resolvePluginProviders,
}));
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
handleReset: mocks.handleReset,
}));
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
}
function expectResetCall(params: { scope: string; runtime: RuntimeEnv; workspace?: string }): void {
const calls = mocks.handleReset.mock.calls as unknown as Array<[string, string, RuntimeEnv]>;
const call = calls[0];
if (!call) {
throw new Error("expected handleReset call");
}
expect(call[0]).toBe(params.scope);
if (params.workspace) {
expect(call[1]).toBe(params.workspace);
} else {
expect(typeof call[1]).toBe("string");
}
expect(call[2]).toBe(params.runtime);
}
const localResetProviderCases = [
{ providerId: "ollama", methodId: "local" },
{ providerId: "lmstudio", methodId: "custom" },
] as const;
function mockLocalResetPreflight(params: {
providerId: (typeof localResetProviderCases)[number]["providerId"];
methodId: (typeof localResetProviderCases)[number]["methodId"];
validationResult: boolean;
}) {
const validateNonInteractive = vi.fn(
async (ctx: ProviderAuthMethodNonInteractiveValidationContext) => {
if (!params.validationResult) {
ctx.runtime.error("Local provider preflight failed");
ctx.runtime.exit(1);
}
return params.validationResult;
},
);
const runNonInteractive = vi.fn(async () => ({}));
mocks.resolvePluginProviders.mockReturnValueOnce([
{
id: params.providerId,
label: params.providerId === "ollama" ? "Ollama" : "LM Studio",
auth: [
{
id: params.methodId,
label: "Local provider",
kind: "custom",
run: vi.fn(async () => ({ profiles: [] })),
runNonInteractive,
validateNonInteractive,
},
],
},
]);
return { runNonInteractive, validateNonInteractive };
}
describe("setupWizardCommand", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
mocks.hasInteractiveOnboardingTty.mockReturnValue(true);
mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} });
});
it("fails fast for invalid secret-input-mode before setup starts", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
secretInputMode: "invalid" as never, // pragma: allowlist secret
},
runtime,
);
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --secret-input-mode. Use "plaintext" or "ref", or run ${formatCliCommand("openclaw onboard")} for the interactive setup.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("logs ASCII-safe Windows guidance before setup", async () => {
const runtime = makeRuntime();
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await setupWizardCommand({}, runtime);
expect(runtime.log).toHaveBeenCalledWith(
[
"Windows detected - OpenClaw runs great on WSL2!",
"Native Windows might be trickier.",
"Quick setup: wsl --install (one command, one reboot)",
"Guide: https://docs.openclaw.ai/windows",
].join("\n"),
);
} finally {
platformSpy.mockRestore();
}
});
it("defaults --reset to config+creds+sessions scope", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
},
runtime,
);
expectResetCall({ scope: "config+creds+sessions", runtime });
});
it.each([
["guided", { reset: true }],
["classic", { reset: true, classic: true }],
] as const)("rejects headless %s onboarding before reset", async (_label, options) => {
const runtime = makeRuntime();
mocks.hasInteractiveOnboardingTty.mockReturnValue(false);
await setupWizardCommand(options, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled();
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("keeps non-interactive reset ordering without a TTY", async () => {
const runtime = makeRuntime();
mocks.hasInteractiveOnboardingTty.mockReturnValue(false);
await setupWizardCommand({ reset: true, nonInteractive: true, acceptRisk: true }, runtime);
expect(mocks.handleReset).toHaveBeenCalledOnce();
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
const resetOrder = mocks.handleReset.mock.invocationCallOrder[0];
const setupOrder = mocks.runNonInteractiveSetup.mock.invocationCallOrder[0];
if (resetOrder === undefined || setupOrder === undefined) {
throw new Error("expected reset and non-interactive setup calls");
}
expect(resetOrder).toBeLessThan(setupOrder);
});
it("uses configured default workspace for --reset when --workspace is not provided", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
agents: {
defaults: {
workspace: "/tmp/openclaw-custom-workspace",
},
},
},
});
await setupWizardCommand(
{
reset: true,
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"config+creds+sessions",
path.resolve("/tmp/openclaw-custom-workspace"),
runtime,
);
});
it("uses the parsed workspace for a full reset when the config schema is invalid", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {
agents: {
defaults: {
workspace: "/tmp/openclaw-invalid-config-workspace",
},
},
},
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"full",
path.resolve("/tmp/openclaw-invalid-config-workspace"),
runtime,
);
expect(mocks.handleReset).not.toHaveBeenCalledWith(
"full",
path.resolve("~/.openclaw/workspace"),
runtime,
);
});
it("does not fall back to the default workspace when invalid config names no valid path", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {
agents: {
defaults: {
workspace: 42,
},
},
} as unknown as OpenClawConfig,
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("requires an explicit workspace for a full reset when config is unreadable", async () => {
const runtime = makeRuntime();
// readConfigFileSnapshot always returns a sourceConfig object, so an
// unreadable config is only recognizable through readError.
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {},
readError: { code: "EACCES" },
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("uses the default workspace for a full reset when a readable config configures none", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: { gateway: { port: 1 } },
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"full",
resolveUserPath("~/.openclaw/workspace"),
runtime,
);
});
it("accepts explicit --reset-scope full", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expectResetCall({ scope: "full", runtime });
});
it("fails fast for invalid --reset-scope", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
resetScope: "invalid" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --reset-scope. Use "config", "config+creds+sessions", or "full". Run ${formatCliCommand("openclaw onboard --reset --reset-scope config")} for a config-only reset.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects --reset-scope without --reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
resetScope: "full",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"--reset-scope requires --reset. Re-run with openclaw onboard --reset --reset-scope full.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
});
it("fails fast for invalid non-interactive --mode before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "typo" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --mode "typo". Use "local" or "remote", or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("fails fast for an empty non-interactive --mode before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --mode "". Use "local" or "remote", or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("validates a remote URL before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "remote",
remoteUrl: "https://example.com",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(expect.any(String));
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it.each([
{
label: "unsupported flow",
options: { flow: "bogus" as never },
expectedError: "Invalid --flow",
},
{
label: "malformed remote URL",
options: { mode: "remote" as const, remoteUrl: "garbage" },
expectedError: "URL must start with ws:// or wss://",
},
{
label: "non-WebSocket remote URL",
options: { mode: "remote" as const, remoteUrl: "https://example.invalid" },
expectedError: "URL must start with ws:// or wss://",
},
{
label: "remote URL in local mode",
options: { mode: "local" as const, remoteUrl: "wss://gateway.example.invalid" },
expectedError: "--remote-url requires --mode remote in non-interactive setup.",
},
{
label: "remote token in default local mode",
options: { remoteToken: "fixture-token" },
expectedError: "--remote-token requires --mode remote in non-interactive setup.",
},
{
label: "unsupported daemon runtime while daemon install is skipped",
options: { daemonRuntime: "bogus" as never, installDaemon: false },
expectedError: "Invalid --daemon-runtime",
},
{
label: "unsupported node manager",
options: { nodeManager: "bogus" as never },
expectedError: "Invalid --node-manager",
},
])(
"rejects $label before non-interactive setup without reset",
async ({ options, expectedError }) => {
const runtime = makeRuntime();
await setupWizardCommand({ nonInteractive: true, acceptRisk: true, ...options }, runtime);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(expectedError));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled();
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
},
);
it("validates dependent gateway options before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
gatewayAuth: "password",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Missing --gateway-password for password auth. Pass --gateway-password or use --gateway-auth token.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates gateway token env refs before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
gatewayTokenRefEnv: "MISSING_GATEWAY_TOKEN_ENV",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining('Environment variable "MISSING_GATEWAY_TOKEN_ENV" is missing'),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects conflicting gateway token inputs before reset", async () => {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
const runtime = makeRuntime();
try {
await setupWizardCommand(
{
reset: true,
gatewayToken: "plaintext-token",
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
},
runtime,
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Use either --gateway-token or --gateway-token-ref-env"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it("validates dependent auth-choice options before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "token",
token: "value",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "token" requires --token-provider in non-interactive setup.',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates a required setup token before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "setup-token",
tokenProvider: "anthropic",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "setup-token" requires --token in non-interactive setup.',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates setup-token expiry before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "setup-token",
tokenProvider: "anthropic",
token: "test-token",
tokenExpiresIn: "nope",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith("Invalid --token-expires-in: invalid duration");
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates the token provider before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "token",
tokenProvider: "typo",
token: "value",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "token" was not matched to provider "typo".',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it.each(localResetProviderCases)(
"validates $providerId exactly once before reset and non-interactive setup",
async ({ providerId, methodId }) => {
const runtime = makeRuntime();
const { runNonInteractive, validateNonInteractive } = mockLocalResetPreflight({
providerId,
methodId,
validationResult: true,
});
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: providerId,
},
runtime,
);
expect(validateNonInteractive).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
authChoice: providerId,
config: {},
baseConfig: {},
opts: expect.objectContaining({ reset: true, nonInteractive: true }),
runtime,
}),
);
expect(mocks.handleReset).toHaveBeenCalledOnce();
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
expect(runNonInteractive).not.toHaveBeenCalled();
const validationCall = validateNonInteractive.mock.invocationCallOrder.at(0);
const resetCall = mocks.handleReset.mock.invocationCallOrder.at(0);
const setupCall = mocks.runNonInteractiveSetup.mock.invocationCallOrder.at(0);
if (validationCall === undefined || resetCall === undefined || setupCall === undefined) {
throw new Error("Expected local provider validation, reset, and onboarding setup");
}
expect(validationCall).toBeLessThan(resetCall);
expect(resetCall).toBeLessThan(setupCall);
},
);
it.each(localResetProviderCases)(
"never resets or starts setup when $providerId validation fails",
async ({ providerId, methodId }) => {
const runtime = makeRuntime();
const { runNonInteractive, validateNonInteractive } = mockLocalResetPreflight({
providerId,
methodId,
validationResult: false,
});
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: providerId,
},
runtime,
);
expect(validateNonInteractive).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith("Local provider preflight failed");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
expect(runNonInteractive).not.toHaveBeenCalled();
},
);
it("validates a provider-specific API key before reset", async () => {
const runtime = makeRuntime();
vi.stubEnv("ANTHROPIC_API_KEY", "");
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "apiKey",
tokenProvider: "anthropic",
anthropicApiKey: "",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Missing --anthropic-api-key (or ANTHROPIC_API_KEY in env). Export ANTHROPIC_API_KEY, pass --anthropic-api-key, or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates an inferred custom auth choice before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
customBaseUrl: "https://example.com/v1",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
[
'Auth choice "custom-api-key" requires a base URL and model ID.',
"Use --custom-base-url and --custom-model-id.",
].join("\n"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates custom credential storage before reset", async () => {
const runtime = makeRuntime();
vi.stubEnv("CUSTOM_API_KEY", "");
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
customBaseUrl: "https://example.com/v1",
customModelId: "test-model",
customApiKey: "test-token",
secretInputMode: "ref",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
[
"--custom-api-key cannot be used with --secret-input-mode ref unless CUSTOM_API_KEY is set in env.",
"Set CUSTOM_API_KEY in env and omit --custom-api-key, or use --secret-input-mode plaintext.",
].join("\n"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects migration import before reset because provider input is not preplanned", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
flow: "import",
importFrom: "hermes",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Migration import cannot be combined with --reset because provider input must be planned before any state is removed. Run the import without --reset.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("routes flagless interactive onboarding to the guided flow", async () => {
const runtime = makeRuntime();
// Unset Commander booleans arrive as false and must not force classic.
await setupWizardCommand(
{
skipChannels: false,
skipSkills: false,
acceptRisk: false,
json: false,
tailscaleResetOnExit: undefined,
customImageInput: undefined,
},
runtime,
);
expect(mocks.runGuidedOnboarding).toHaveBeenCalledOnce();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it.each([
["--tui", { tui: true }],
["--skip-ui", { skipUi: true }],
])("keeps %s on guided onboarding", async (_label, opts) => {
const runtime = makeRuntime();
await setupWizardCommand(opts, runtime);
expect(mocks.runGuidedOnboarding).toHaveBeenCalledWith(opts, runtime);
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it.each([
["--classic", { classic: true }],
["--flow quickstart", { flow: "quickstart" as const }],
["--mode remote", { mode: "remote" as const }],
["--import-from", { importFrom: "hermes" }],
["--auth-choice", { authChoice: "skip" }],
["--gateway-port", { gatewayPort: 19001 }],
["--remote-url", { remoteUrl: "wss://gw.example.ts.net" }],
["--skip-bootstrap", { skipBootstrap: true }],
["--no-install-daemon", { installDaemon: false }],
["--no-tailscale-reset-on-exit", { tailscaleResetOnExit: false }],
["--custom-text-input", { customImageInput: false }],
["--daemon-runtime", { daemonRuntime: "node" as const }],
["a provider auth flag", { mistralApiKey: "sk-x" }],
])("keeps the classic interactive wizard for %s", async (_label, opts) => {
const runtime = makeRuntime();
await setupWizardCommand(opts, runtime);
expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
});
it("keeps non-interactive routing unchanged", async () => {
const runtime = makeRuntime();
await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime);
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects conflicting classic and non-interactive modes", async () => {
const runtime = makeRuntime();
await setupWizardCommand({ classic: true, nonInteractive: true, acceptRisk: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"--classic cannot be combined with --non-interactive. Remove --non-interactive to open the classic wizard, or remove --classic for automated setup.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
});
it("rejects conflicting TUI and non-interactive modes", async () => {
const runtime = makeRuntime();
await setupWizardCommand({ tui: true, nonInteractive: true, acceptRisk: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"--tui cannot be combined with --non-interactive. Remove --tui for automation, or remove --non-interactive to open the terminal hatch.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
});
});