fix: harden onboarding for non-systemd environments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
35
src/daemon/systemd-availability.test.ts
Normal file
35
src/daemon/systemd-availability.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
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