fix: harden onboarding for non-systemd environments
This commit is contained in:
120
src/wizard/onboarding.test.ts
Normal file
120
src/wizard/onboarding.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { runOnboardingWizard } from "./onboarding.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
|
||||
const setupProviders = vi.hoisted(() => vi.fn(async (cfg) => cfg));
|
||||
const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg));
|
||||
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const writeConfigFile = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const readConfigFileSnapshot = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ exists: false, valid: true, config: {} })),
|
||||
);
|
||||
const ensureSystemdUserLingerInteractive = vi.hoisted(() =>
|
||||
vi.fn(async () => {}),
|
||||
);
|
||||
const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true));
|
||||
const ensureControlUiAssetsBuilt = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ ok: true })),
|
||||
);
|
||||
const runTui = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("../commands/onboard-providers.js", () => ({
|
||||
setupProviders,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-skills.js", () => ({
|
||||
setupSkills,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
healthCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<typeof import("../commands/onboard-helpers.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureWorkspaceAndSessions,
|
||||
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
|
||||
openUrl: vi.fn(async () => true),
|
||||
printWizardHeader: vi.fn(),
|
||||
probeGatewayReachable: vi.fn(async () => ({ ok: true })),
|
||||
resolveControlUiLinks: vi.fn(() => ({
|
||||
httpUrl: "http://127.0.0.1:18789",
|
||||
wsUrl: "ws://127.0.0.1:18789",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../commands/systemd-linger.js", () => ({
|
||||
ensureSystemdUserLingerInteractive,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/systemd.js", () => ({
|
||||
isSystemdUserServiceAvailable,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/control-ui-assets.js", () => ({
|
||||
ensureControlUiAssetsBuilt,
|
||||
}));
|
||||
|
||||
vi.mock("../tui/tui.js", () => ({
|
||||
runTui,
|
||||
}));
|
||||
|
||||
describe("runOnboardingWizard", () => {
|
||||
it("skips prompts and setup steps when flags are set", async () => {
|
||||
const select: WizardPrompter["select"] = vi.fn(async () => "quickstart");
|
||||
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select,
|
||||
multiselect,
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
await runOnboardingWizard(
|
||||
{
|
||||
flow: "quickstart",
|
||||
authChoice: "skip",
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
runtime,
|
||||
prompter,
|
||||
);
|
||||
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(setupProviders).not.toHaveBeenCalled();
|
||||
expect(setupSkills).not.toHaveBeenCalled();
|
||||
expect(healthCommand).not.toHaveBeenCalled();
|
||||
expect(runTui).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -120,14 +121,26 @@ export async function runOnboardingWizard(
|
||||
|
||||
const quickstartHint = "Configure details later via clawdbot configure.";
|
||||
const advancedHint = "Configure port, network, Tailscale, and auth options.";
|
||||
let flow = (await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Advanced", hint: advancedHint },
|
||||
],
|
||||
initialValue: "quickstart",
|
||||
})) as "quickstart" | "advanced";
|
||||
const explicitFlow = opts.flow?.trim();
|
||||
if (
|
||||
explicitFlow &&
|
||||
explicitFlow !== "quickstart" &&
|
||||
explicitFlow !== "advanced"
|
||||
) {
|
||||
runtime.error("Invalid --flow (use quickstart or advanced).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
let flow =
|
||||
explicitFlow ??
|
||||
((await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Advanced", hint: advancedHint },
|
||||
],
|
||||
initialValue: "quickstart",
|
||||
})) as "quickstart" | "advanced");
|
||||
|
||||
if (opts.mode === "remote" && flow === "quickstart") {
|
||||
await prompter.note(
|
||||
@@ -309,14 +322,16 @@ export async function runOnboardingWizard(
|
||||
const authStore = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const authChoice = (await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: buildAuthChoiceOptions({
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
}),
|
||||
})) as AuthChoice;
|
||||
const authChoice =
|
||||
opts.authChoice ??
|
||||
((await prompter.select({
|
||||
message: "Model/auth choice",
|
||||
options: buildAuthChoiceOptions({
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
}),
|
||||
})) as AuthChoice);
|
||||
|
||||
const authResult = await applyAuthChoice({
|
||||
authChoice,
|
||||
@@ -501,14 +516,18 @@ export async function runOnboardingWizard(
|
||||
},
|
||||
};
|
||||
|
||||
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
forceAllowFromProviders:
|
||||
flow === "quickstart" ? ["telegram", "whatsapp"] : [],
|
||||
skipDmPolicyPrompt: flow === "quickstart",
|
||||
skipConfirm: flow === "quickstart",
|
||||
quickstartDefaults: flow === "quickstart",
|
||||
});
|
||||
if (opts.skipProviders) {
|
||||
await prompter.note("Skipping provider setup.", "Providers");
|
||||
} else {
|
||||
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
forceAllowFromProviders:
|
||||
flow === "quickstart" ? ["telegram", "whatsapp"] : [],
|
||||
skipDmPolicyPrompt: flow === "quickstart",
|
||||
skipConfirm: flow === "quickstart",
|
||||
quickstartDefaults: flow === "quickstart",
|
||||
});
|
||||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
@@ -516,28 +535,59 @@ export async function runOnboardingWizard(
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
});
|
||||
|
||||
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
|
||||
if (opts.skipSkills) {
|
||||
await prompter.note("Skipping skills setup.", "Skills");
|
||||
} else {
|
||||
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
|
||||
}
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
|
||||
await ensureSystemdUserLingerInteractive({
|
||||
runtime,
|
||||
prompter: {
|
||||
confirm: prompter.confirm,
|
||||
note: prompter.note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
requireConfirm: false,
|
||||
});
|
||||
const systemdAvailable =
|
||||
process.platform === "linux" ? await isSystemdUserServiceAvailable() : true;
|
||||
if (process.platform === "linux" && !systemdAvailable) {
|
||||
await prompter.note(
|
||||
"Systemd user services are unavailable. Skipping lingering checks and daemon install.",
|
||||
"Systemd",
|
||||
);
|
||||
}
|
||||
|
||||
const installDaemon =
|
||||
flow === "quickstart"
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: "Install Gateway daemon (recommended)",
|
||||
initialValue: true,
|
||||
});
|
||||
if (process.platform === "linux" && systemdAvailable) {
|
||||
await ensureSystemdUserLingerInteractive({
|
||||
runtime,
|
||||
prompter: {
|
||||
confirm: prompter.confirm,
|
||||
note: prompter.note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
requireConfirm: false,
|
||||
});
|
||||
}
|
||||
|
||||
const explicitInstallDaemon =
|
||||
typeof opts.installDaemon === "boolean" ? opts.installDaemon : undefined;
|
||||
let installDaemon: boolean;
|
||||
if (explicitInstallDaemon !== undefined) {
|
||||
installDaemon = explicitInstallDaemon;
|
||||
} else if (process.platform === "linux" && !systemdAvailable) {
|
||||
installDaemon = false;
|
||||
} else if (flow === "quickstart") {
|
||||
installDaemon = true;
|
||||
} else {
|
||||
installDaemon = await prompter.confirm({
|
||||
message: "Install Gateway daemon (recommended)",
|
||||
initialValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === "linux" && !systemdAvailable && installDaemon) {
|
||||
await prompter.note(
|
||||
"Systemd user services are unavailable; skipping daemon install. Use your container supervisor or `docker compose up -d`.",
|
||||
"Gateway daemon",
|
||||
);
|
||||
installDaemon = false;
|
||||
}
|
||||
|
||||
if (installDaemon) {
|
||||
const daemonRuntime =
|
||||
@@ -609,19 +659,21 @@ export async function runOnboardingWizard(
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(1500);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
await prompter.note(
|
||||
[
|
||||
"Docs:",
|
||||
"https://docs.clawd.bot/gateway/health",
|
||||
"https://docs.clawd.bot/gateway/troubleshooting",
|
||||
].join("\n"),
|
||||
"Health check help",
|
||||
);
|
||||
if (!opts.skipHealth) {
|
||||
await sleep(1500);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(`Health check failed: ${String(err)}`);
|
||||
await prompter.note(
|
||||
[
|
||||
"Docs:",
|
||||
"https://docs.clawd.bot/gateway/health",
|
||||
"https://docs.clawd.bot/gateway/troubleshooting",
|
||||
].join("\n"),
|
||||
"Health check help",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
@@ -676,7 +728,7 @@ export async function runOnboardingWizard(
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
if (gatewayProbe.ok) {
|
||||
if (!opts.skipUi && gatewayProbe.ok) {
|
||||
if (hasBootstrap) {
|
||||
await prompter.note(
|
||||
[
|
||||
@@ -731,6 +783,8 @@ export async function runOnboardingWizard(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (opts.skipUi) {
|
||||
await prompter.note("Skipping Control UI/TUI prompts.", "Control UI");
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
|
||||
Reference in New Issue
Block a user