diff --git a/CHANGELOG.md b/CHANGELOG.md index 348db8252..f33ed8387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. - Skills: allow `bun` as a node manager for skill installs. +- Tests: add a Docker-based onboarding E2E harness. ### Fixes - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b diff --git a/docs/test.md b/docs/test.md index bb0fc2da4..98f6bec7e 100644 --- a/docs/test.md +++ b/docs/test.md @@ -20,3 +20,11 @@ Usage: Last run (2025-12-31, 20 runs): - minimax median 1279ms (min 1114, max 2431) - opus median 2454ms (min 1224, max 3170) + +## Onboarding E2E (Docker) + +Full cold-start flow in a clean Linux container: + +```bash +scripts/e2e/onboard-docker.sh +``` diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile new file mode 100644 index 000000000..f706a2374 --- /dev/null +++ b/scripts/e2e/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-bookworm + +RUN corepack enable + +WORKDIR /app +COPY . . + +RUN pnpm install --frozen-lockfile +RUN pnpm build + +CMD ["bash"] diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh new file mode 100755 index 000000000..ea6e9a3dc --- /dev/null +++ b/scripts/e2e/onboard-docker.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="clawdis-onboard-e2e" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Running onboarding E2E..." +docker run --rm -t "$IMAGE_NAME" bash -lc ' + set -euo pipefail + + node dist/index.js onboard \ + --non-interactive \ + --mode local \ + --workspace /root/clawd \ + --auth-choice skip \ + --gateway-port 18789 \ + --gateway-bind loopback \ + --gateway-auth off \ + --tailscale off \ + --skip-skills \ + --skip-health \ + --json + + node dist/index.js gateway-daemon --port 18789 --bind loopback > /tmp/gateway.log 2>&1 & + GW_PID=$! + sleep 2 + + node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1) + + kill "$GW_PID" + wait "$GW_PID" || true +' + +echo "E2E complete." diff --git a/src/cli/program.ts b/src/cli/program.ts index 40b231311..e74851b73 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -107,11 +107,17 @@ export function buildProgram() { "Agent workspace directory (default: ~/clawd; stored as agent.workspace)", ) .option("--wizard", "Run the interactive onboarding wizard", false) + .option("--non-interactive", "Run the wizard without prompts", false) + .option("--mode ", "Wizard mode: local|remote") .action(async (opts) => { try { if (opts.wizard) { await onboardCommand( - { workspace: opts.workspace as string | undefined }, + { + workspace: opts.workspace as string | undefined, + nonInteractive: Boolean(opts.nonInteractive), + mode: opts.mode as "local" | "remote" | undefined, + }, defaultRuntime, ); return; @@ -130,10 +136,58 @@ export function buildProgram() { .command("onboard") .description("Interactive wizard to set up the gateway, workspace, and skills") .option("--workspace ", "Agent workspace directory (default: ~/clawd)") + .option("--non-interactive", "Run without prompts", false) + .option("--mode ", "Wizard mode: local|remote") + .option("--auth-choice ", "Auth: oauth|apiKey|minimax|skip") + .option("--anthropic-api-key ", "Anthropic API key") + .option("--gateway-port ", "Gateway port", "18789") + .option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto") + .option("--gateway-auth ", "Gateway auth: off|token|password") + .option("--gateway-token ", "Gateway token (token auth)") + .option("--gateway-password ", "Gateway password (password auth)") + .option("--tailscale ", "Tailscale: off|serve|funnel") + .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") + .option("--install-daemon", "Install gateway daemon") + .option("--skip-skills", "Skip skills setup") + .option("--skip-health", "Skip health check") + .option("--node-manager ", "Node manager for skills: npm|pnpm|bun") + .option("--json", "Output JSON summary", false) .action(async (opts) => { try { await onboardCommand( - { workspace: opts.workspace as string | undefined }, + { + workspace: opts.workspace as string | undefined, + nonInteractive: Boolean(opts.nonInteractive), + mode: opts.mode as "local" | "remote" | undefined, + authChoice: opts.authChoice as + | "oauth" + | "apiKey" + | "minimax" + | "skip" + | undefined, + anthropicApiKey: opts.anthropicApiKey as string | undefined, + gatewayPort: Number.parseInt(String(opts.gatewayPort ?? "18789"), 10), + gatewayBind: opts.gatewayBind as + | "loopback" + | "lan" + | "tailnet" + | "auto" + | undefined, + gatewayAuth: opts.gatewayAuth as + | "off" + | "token" + | "password" + | undefined, + gatewayToken: opts.gatewayToken as string | undefined, + gatewayPassword: opts.gatewayPassword as string | undefined, + tailscale: opts.tailscale as "off" | "serve" | "funnel" | undefined, + tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit), + installDaemon: Boolean(opts.installDaemon), + skipSkills: Boolean(opts.skipSkills), + skipHealth: Boolean(opts.skipHealth), + nodeManager: opts.nodeManager as "npm" | "pnpm" | "bun" | undefined, + json: Boolean(opts.json), + }, defaultRuntime, ); } catch (err) { diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 4b938a5d2..8dcc6ffad 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -50,6 +50,20 @@ type OnboardOptions = { mode?: OnboardMode; workspace?: string; nonInteractive?: boolean; + authChoice?: AuthChoice; + anthropicApiKey?: string; + gatewayPort?: number; + gatewayBind?: "loopback" | "lan" | "tailnet" | "auto"; + gatewayAuth?: GatewayAuthChoice; + gatewayToken?: string; + gatewayPassword?: string; + tailscale?: "off" | "serve" | "funnel"; + tailscaleResetOnExit?: boolean; + installDaemon?: boolean; + skipSkills?: boolean; + skipHealth?: boolean; + nodeManager?: "npm" | "pnpm" | "bun"; + json?: boolean; }; function guardCancel(value: T, runtime: RuntimeEnv): T { @@ -345,8 +359,193 @@ export async function onboardCommand( assertSupportedRuntime(runtime); if (opts.nonInteractive) { - runtime.error("Non-interactive mode is not implemented yet."); - runtime.exit(1); + const snapshot = await readConfigFileSnapshot(); + const baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; + const mode: OnboardMode = opts.mode ?? "local"; + + if (mode === "remote") { + const payload = { + mode, + instructions: [ + "clawdis setup", + "clawdis gateway-daemon --port 18789", + "OAuth creds: ~/.clawdis/credentials/oauth.json", + "Workspace: ~/clawd", + ], + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } else { + runtime.log(payload.instructions.join("\n")); + } + return; + } + + const workspaceDir = resolveUserPath( + (opts.workspace ?? baseConfig.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR) + .trim(), + ); + + let nextConfig: ClawdisConfig = { + ...baseConfig, + agent: { + ...baseConfig.agent, + workspace: workspaceDir, + }, + gateway: { + ...baseConfig.gateway, + mode: "local", + }, + }; + + const authChoice: AuthChoice = opts.authChoice ?? "skip"; + if (authChoice === "apiKey") { + const key = opts.anthropicApiKey?.trim(); + if (!key) { + runtime.error("Missing --anthropic-api-key"); + runtime.exit(1); + return; + } + await setAnthropicApiKey(key); + } else if (authChoice === "minimax") { + nextConfig = applyMinimaxConfig(nextConfig); + } else if (authChoice === "oauth") { + runtime.error("OAuth requires interactive mode."); + runtime.exit(1); + return; + } + + const port = opts.gatewayPort ?? 18789; + if (!Number.isFinite(port) || port <= 0) { + runtime.error("Invalid --gateway-port"); + runtime.exit(1); + return; + } + let bind = opts.gatewayBind ?? "loopback"; + let authMode = opts.gatewayAuth ?? "off"; + const tailscaleMode = opts.tailscale ?? "off"; + const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); + + if (tailscaleMode !== "off" && bind !== "loopback") { + bind = "loopback"; + } + if (authMode === "off" && bind !== "loopback") { + authMode = "token"; + } + if (tailscaleMode === "funnel" && authMode !== "password") { + authMode = "password"; + } + + let gatewayToken = opts.gatewayToken?.trim() || undefined; + if (authMode === "token") { + if (!gatewayToken) gatewayToken = randomToken(); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { ...nextConfig.gateway?.auth, mode: "token" }, + }, + }; + } + if (authMode === "password") { + const password = opts.gatewayPassword?.trim(); + if (!password) { + runtime.error("Missing --gateway-password for password auth."); + runtime.exit(1); + return; + } + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "password", + password, + }, + }, + }; + } + + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + bind, + tailscale: { + ...nextConfig.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + if (!opts.skipSkills) { + const nodeManager = opts.nodeManager ?? "npm"; + if (!["npm", "pnpm", "bun"].includes(nodeManager)) { + runtime.error("Invalid --node-manager (use npm, pnpm, or bun)"); + runtime.exit(1); + return; + } + nextConfig = { + ...nextConfig, + skills: { + ...nextConfig.skills, + install: { + ...nextConfig.skills?.install, + nodeManager, + }, + }, + }; + } + + await writeConfigFile(nextConfig); + await ensureWorkspaceAndSessions(workspaceDir, runtime); + + if (opts.installDaemon) { + const service = resolveGatewayService(); + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDIS_GATEWAY_TOKEN: gatewayToken, + CLAWDIS_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } + + if (!opts.skipHealth) { + await sleep(1000); + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } + + if (opts.json) { + runtime.log( + JSON.stringify( + { + mode, + workspace: workspaceDir, + authChoice, + gateway: { port, bind, authMode, tailscaleMode }, + installDaemon: Boolean(opts.installDaemon), + skipSkills: Boolean(opts.skipSkills), + skipHealth: Boolean(opts.skipHealth), + }, + null, + 2, + ), + ); + } return; }