From 55e830b00902b1195607c851c953a7c6bafe8655 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 22:16:17 +0100 Subject: [PATCH] fix: harden onboarding for non-systemd environments --- CHANGELOG.md | 1 + docker-setup.sh | 2 +- scripts/e2e/onboard-docker.sh | 131 ++++++++----------- src/cli/program.ts | 20 ++- src/commands/onboard-non-interactive.ts | 79 ++++++----- src/commands/onboard-types.ts | 3 + src/commands/systemd-linger.ts | 9 ++ src/daemon/systemd-availability.test.ts | 35 +++++ src/daemon/systemd.ts | 13 ++ src/wizard/onboarding.test.ts | 120 +++++++++++++++++ src/wizard/onboarding.ts | 166 ++++++++++++++++-------- 11 files changed, 409 insertions(+), 170 deletions(-) create mode 100644 src/daemon/systemd-availability.test.ts create mode 100644 src/wizard/onboarding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfddb45e3..f54180a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis +- Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete - macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete - Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe diff --git a/docker-setup.sh b/docker-setup.sh index 5c8c2539f..3e168e4d2 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -98,7 +98,7 @@ echo " - Gateway token: $CLAWDBOT_GATEWAY_TOKEN" echo " - Tailscale exposure: Off" echo " - Install Gateway daemon: No" echo "" -docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard +docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard --no-install-daemon echo "" echo "==> Provider setup (optional)" diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 8b3259d92..8618cff81 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -12,6 +12,7 @@ docker run --rm -t "$IMAGE_NAME" bash -lc ' set -euo pipefail trap "" PIPE export TERM=xterm-256color + ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-providers --skip-skills --skip-daemon --skip-ui" # Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. export PATH="/tmp/clawdbot-bin:$PATH" @@ -42,7 +43,7 @@ TRASH } start_gateway() { - node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway-e2e.log 2>&1 & + node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 & GATEWAY_PID="$!" } @@ -71,6 +72,7 @@ TRASH local command="$3" local send_fn="$4" local with_gateway="${5:-false}" + local validate_fn="${6:-}" echo "== Wizard case: $case_name ==" export HOME="$home_dir" @@ -78,8 +80,9 @@ TRASH input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")" mkfifo "$input_fifo" + local log_path="/tmp/clawdbot-onboard-${case_name}.log" # Run under script to keep an interactive TTY for clack prompts. - script -q -c "$command" /dev/null < "$input_fifo" & + script -q -c "$command" "$log_path" < "$input_fifo" & wizard_pid=$! exec 3> "$input_fifo" @@ -96,15 +99,19 @@ TRASH wait "$wizard_pid" rm -f "$input_fifo" stop_gateway "$gw_pid" + if [ -n "$validate_fn" ]; then + "$validate_fn" "$log_path" + fi } run_wizard() { local case_name="$1" local home_dir="$2" local send_fn="$3" + local validate_fn="${4:-}" # Default onboarding command wrapper. - run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard" "$send_fn" true + run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn" } make_home() { @@ -129,23 +136,7 @@ TRASH send_local_basic() { # Choose local gateway, accept defaults, skip provider/skills/daemon, skip UI. - send $'"'"'\r'"'"' 1.0 - send $'"'"'\r'"'"' 1.0 - send "" 1.2 - send $'"'"'\e[B'"'"' 0.6 - send $'"'"'\e[B'"'"' 0.6 - send $'"'"'\e[B'"'"' 0.6 - send $'"'"'\e[B'"'"' 0.6 - send $'"'"'\e[B'"'"' 0.6 - send $'"'"'\r'"'"' 0.6 send $'"'"'\r'"'"' 0.5 - send $'"'"'\r'"'"' 0.5 - send $'"'"'\r'"'"' 0.5 - send $'"'"'\r'"'"' 0.5 - send $'"'"'n\r'"'"' 0.5 - send $'"'"'n\r'"'"' 0.5 - send $'"'"'n\r'"'"' 0.5 - send $'"'"'n\r'"'"' 0.5 } send_reset_config_only() { @@ -160,62 +151,31 @@ TRASH send_providers_flow() { # Configure providers via configure wizard. - send "" 0.6 + send $'"'"'\r'"'"' 1.0 + send "" 1.5 + # Provider mode (default Configure providers) send $'"'"'\r'"'"' 0.8 - send "" 1.2 - # Select Providers section only. - send $'"'"'\e[B'"'"' 0.5 - send $'"'"'\e[B'"'"' 0.5 - send $'"'"'\e[B'"'"' 0.5 - send $'"'"'\e[B'"'"' 0.5 - send $'"'"' '"'"' 0.4 - send $'"'"'\r'"'"' 0.6 - # Configure providers now? (default Yes) - send $'"'"'\r'"'"' 0.8 - send "" 0.8 - # Select Telegram, Discord, Slack. - send $'"'"'\e[B'"'"' 0.4 - send $'"'"' '"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"' '"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"' '"'"' 0.4 - send $'"'"'\r'"'"' 0.6 - send $'"'"'tg_token\r'"'"' 0.8 - send $'"'"'discord_token\r'"'"' 0.8 - send "" 0.6 - send $'"'"'\r'"'"' 0.6 - send "" 0.6 - send $'"'"'slack_bot\r'"'"' 0.8 - send "" 0.6 - send $'"'"'slack_app\r'"'"' 0.8 + send "" 1.0 + # Configure chat providers now? -> No + send $'"'"'n\r'"'"' 0.6 } send_skills_flow() { # Select skills section and skip optional installs. - send "" 0.6 - send $'"'"'\r'"'"' 0.6 - send "" 1.0 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"'\e[B'"'"' 0.4 - send $'"'"' '"'"' 0.3 - send $'"'"'\r'"'"' 0.4 - send $'"'"'n\r'"'"' 0.4 - send $'"'"'n\r'"'"' 0.4 + send $'"'"'\r'"'"' 1.0 + send "" 1.2 + send $'"'"'n\r'"'"' 0.6 } run_case_local_basic() { local home_dir home_dir="$(make_home local-basic)" - run_wizard local-basic "$home_dir" send_local_basic + run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log # Assert config + workspace scaffolding. workspace_dir="$HOME/clawd" config_path="$HOME/.clawdbot/clawdbot.json" - sessions_dir="$HOME/.clawdbot/sessions" + sessions_dir="$HOME/.clawdbot/agents/main/sessions" assert_file "$config_path" assert_dir "$sessions_dir" @@ -383,7 +343,7 @@ NODE local home_dir home_dir="$(make_home providers)" # Providers-only configure flow. - run_wizard_cmd providers "$home_dir" "node dist/index.js configure" send_providers_flow + run_wizard_cmd providers "$home_dir" "node dist/index.js configure --section providers" send_providers_flow config_path="$HOME/.clawdbot/clawdbot.json" assert_file "$config_path" @@ -395,21 +355,22 @@ import JSON5 from "json5"; const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); const errors = []; -if (cfg?.telegram?.botToken !== "tg_token") { - errors.push(`telegram.botToken mismatch (got ${cfg?.telegram?.botToken ?? "unset"})`); -} -if (cfg?.discord?.token !== "discord_token") { - errors.push(`discord.token mismatch (got ${cfg?.discord?.token ?? "unset"})`); -} -if (cfg?.slack?.botToken !== "slack_bot") { - errors.push(`slack.botToken mismatch (got ${cfg?.slack?.botToken ?? "unset"})`); -} -if (cfg?.slack?.appToken !== "slack_app") { - errors.push(`slack.appToken mismatch (got ${cfg?.slack?.appToken ?? "unset"})`); -} -if (cfg?.wizard?.lastRunMode !== "local") { - errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); -} + if (cfg?.telegram?.botToken) { + errors.push(`telegram.botToken should be unset (got ${cfg?.telegram?.botToken})`); + } + if (cfg?.discord?.token) { + errors.push(`discord.token should be unset (got ${cfg?.discord?.token})`); + } + if (cfg?.slack?.botToken || cfg?.slack?.appToken) { + errors.push( + `slack tokens should be unset (got bot=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`, + ); + } + if (cfg?.wizard?.lastRunCommand !== "configure") { + errors.push( + `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, + ); + } if (errors.length > 0) { console.error(errors.join("\n")); @@ -433,7 +394,7 @@ NODE } JSON - run_wizard_cmd skills "$home_dir" "node dist/index.js configure" send_skills_flow + run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow config_path="$HOME/.clawdbot/clawdbot.json" assert_file "$config_path" @@ -462,6 +423,20 @@ if (errors.length > 0) { NODE } + assert_log_not_contains() { + local file_path="$1" + local needle="$2" + if grep -q "$needle" "$file_path"; then + echo "Unexpected log output: $needle" + exit 1 + fi + } + + validate_local_basic_log() { + local log_path="$1" + assert_log_not_contains "$log_path" "systemctl --user unavailable" + } + run_case_local_basic run_case_remote_non_interactive run_case_reset diff --git a/src/cli/program.ts b/src/cli/program.ts index fa1df59c1..cb44f396a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -241,6 +241,7 @@ export function buildProgram() { ) .option("--workspace ", "Agent workspace directory (default: ~/clawd)") .option("--non-interactive", "Run without prompts", false) + .option("--flow ", "Wizard flow: quickstart|advanced") .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", @@ -276,17 +277,30 @@ export function buildProgram() { .option("--tailscale ", "Tailscale: off|serve|funnel") .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") .option("--install-daemon", "Install gateway daemon") + .option("--no-install-daemon", "Skip gateway daemon install") + .option("--skip-daemon", "Skip gateway daemon install") .option("--daemon-runtime ", "Daemon runtime: node|bun") + .option("--skip-providers", "Skip provider setup") .option("--skip-skills", "Skip skills setup") .option("--skip-health", "Skip health check") + .option("--skip-ui", "Skip Control UI/TUI prompts") .option("--node-manager ", "Node manager for skills: npm|pnpm|bun") .option("--json", "Output JSON summary", false) - .action(async (opts) => { + .action(async (opts, command) => { try { + const installDaemon = + typeof command?.getOptionValueSource === "function" + ? command.getOptionValueSource("skipDaemon") === "cli" + ? false + : command.getOptionValueSource("installDaemon") === "cli" + ? Boolean(opts.installDaemon) + : undefined + : undefined; await onboardCommand( { workspace: opts.workspace as string | undefined, nonInteractive: Boolean(opts.nonInteractive), + flow: opts.flow as "quickstart" | "advanced" | undefined, mode: opts.mode as "local" | "remote" | undefined, authChoice: opts.authChoice as | "oauth" @@ -332,10 +346,12 @@ export function buildProgram() { remoteToken: opts.remoteToken as string | undefined, tailscale: opts.tailscale as "off" | "serve" | "funnel" | undefined, tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit), - installDaemon: Boolean(opts.installDaemon), + installDaemon, daemonRuntime: opts.daemonRuntime as "node" | "bun" | undefined, + skipProviders: Boolean(opts.skipProviders), skipSkills: Boolean(opts.skipSkills), skipHealth: Boolean(opts.skipHealth), + skipUi: Boolean(opts.skipUi), nodeManager: opts.nodeManager as "npm" | "pnpm" | "bun" | undefined, json: Boolean(opts.json), }, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 6e2904922..b32e24262 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -19,6 +19,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 { upsertSharedEnvVar } from "../infra/env-file.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -429,41 +430,53 @@ export async function runNonInteractiveOnboarding( const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; if (opts.installDaemon) { - if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) { - runtime.error("Invalid --daemon-runtime (use node or bun)"); - runtime.exit(1); - return; - } - const service = resolveGatewayService(); - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const nodePath = await resolvePreferredNodePath({ - env: process.env, - runtime: daemonRuntimeRaw, - }); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ - port, - dev: devMode, + const systemdAvailable = + process.platform === "linux" + ? await isSystemdUserServiceAvailable() + : true; + if (process.platform === "linux" && !systemdAvailable) { + runtime.log( + "Systemd user services are unavailable; skipping daemon install.", + ); + } else { + if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) { + runtime.error("Invalid --daemon-runtime (use node or bun)"); + runtime.exit(1); + return; + } + const service = resolveGatewayService(); + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const nodePath = await resolvePreferredNodePath({ + env: process.env, runtime: daemonRuntimeRaw, - nodePath, }); - const environment = buildServiceEnvironment({ - env: process.env, - port, - token: gatewayToken, - launchdLabel: - process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }); - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - await ensureSystemdUserLingerNonInteractive({ runtime }); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ + port, + dev: devMode, + runtime: daemonRuntimeRaw, + nodePath, + }); + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: gatewayToken, + launchdLabel: + process.platform === "darwin" + ? GATEWAY_LAUNCH_AGENT_LABEL + : undefined, + }); + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + await ensureSystemdUserLingerNonInteractive({ runtime }); + } } if (!opts.skipHealth) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 359c3d5da..ca741c156 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -27,6 +27,7 @@ export type ProviderChoice = ChatProviderId; export type OnboardOptions = { mode?: OnboardMode; + flow?: "quickstart" | "advanced"; workspace?: string; nonInteractive?: boolean; authChoice?: AuthChoice; @@ -51,8 +52,10 @@ export type OnboardOptions = { tailscaleResetOnExit?: boolean; installDaemon?: boolean; daemonRuntime?: GatewayDaemonRuntime; + skipProviders?: boolean; skipSkills?: boolean; skipHealth?: boolean; + skipUi?: boolean; nodeManager?: NodeManagerChoice; remoteUrl?: string; remoteToken?: string; diff --git a/src/commands/systemd-linger.ts b/src/commands/systemd-linger.ts index f14434c96..3619e2718 100644 --- a/src/commands/systemd-linger.ts +++ b/src/commands/systemd-linger.ts @@ -2,6 +2,7 @@ import { note as clackNote } from "@clack/prompts"; import { enableSystemdUserLinger, + isSystemdUserServiceAvailable, readSystemdUserLingerStatus, } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -32,6 +33,13 @@ export async function ensureSystemdUserLingerInteractive(params: { const env = params.env ?? process.env; const prompter = params.prompter ?? { note }; const title = params.title ?? "Systemd"; + if (!(await isSystemdUserServiceAvailable())) { + await prompter.note( + "Systemd user services are unavailable. Skipping lingering checks.", + title, + ); + return; + } const status = await readSystemdUserLingerStatus(env); if (!status) { await prompter.note( @@ -98,6 +106,7 @@ export async function ensureSystemdUserLingerNonInteractive(params: { }): Promise { if (process.platform !== "linux") return; const env = params.env ?? process.env; + if (!(await isSystemdUserServiceAvailable())) return; const status = await readSystemdUserLingerStatus(env); if (!status || status.linger === "yes") return; diff --git a/src/daemon/systemd-availability.test.ts b/src/daemon/systemd-availability.test.ts new file mode 100644 index 000000000..4897084ce --- /dev/null +++ b/src/daemon/systemd-availability.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + execFile: execFileMock, +})); + +import { isSystemdUserServiceAvailable } from "./systemd.js"; + +describe("systemd availability", () => { + beforeEach(() => { + execFileMock.mockReset(); + }); + + it("returns true when systemctl --user succeeds", async () => { + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, "", ""); + }); + await expect(isSystemdUserServiceAvailable()).resolves.toBe(true); + }); + + it("returns false when systemd user bus is unavailable", async () => { + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + const err = new Error("Failed to connect to bus") as Error & { + stderr?: string; + code?: number; + }; + err.stderr = "Failed to connect to bus"; + err.code = 1; + cb(err, "", ""); + }); + await expect(isSystemdUserServiceAvailable()).resolves.toBe(false); + }); +}); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 3f50cd58a..6d6496c8d 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -337,6 +337,19 @@ async function execSystemctl( } } +export async function isSystemdUserServiceAvailable(): Promise { + const res = await execSystemctl(["--user", "status"]); + if (res.code === 0) return true; + const detail = `${res.stderr} ${res.stdout}`.toLowerCase(); + if (!detail) return false; + if (detail.includes("not found")) return false; + if (detail.includes("failed to connect")) return false; + if (detail.includes("not been booted")) return false; + if (detail.includes("no such file or directory")) return false; + if (detail.includes("not supported")) return false; + return false; +} + async function assertSystemdAvailable() { const res = await execSystemctl(["--user", "status"]); if (res.code === 0) return; diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts new file mode 100644 index 000000000..28f3a4353 --- /dev/null +++ b/src/wizard/onboarding.test.ts @@ -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(); + return { + ...actual, + readConfigFileSnapshot, + writeConfigFile, + }; +}); + +vi.mock("../commands/onboard-helpers.js", async (importActual) => { + const actual = + await importActual(); + 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(); + }); +}); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index baaff9e58..d67750f5f 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -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(