fix: harden onboarding for non-systemd environments

This commit is contained in:
Peter Steinberger
2026-01-09 22:16:17 +01:00
parent 402c35b91c
commit 55e830b009
11 changed files with 409 additions and 170 deletions

View File

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

View File

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

View File

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

View File

@@ -241,6 +241,7 @@ export function buildProgram() {
)
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
.option("--non-interactive", "Run without prompts", false)
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
@@ -276,17 +277,30 @@ export function buildProgram() {
.option("--tailscale <mode>", "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 <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 <name>", "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),
},

View File

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

View File

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

View File

@@ -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<void> {
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;

View File

@@ -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);
});
});

View File

@@ -337,6 +337,19 @@ async function execSystemctl(
}
}
export async function isSystemdUserServiceAvailable(): Promise<boolean> {
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;

View 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();
});
});

View File

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