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(