From 4b2b22d044e352000f18af4702b60a5d17c2bbf6 Mon Sep 17 00:00:00 2001 From: tanmay11k Date: Thu, 19 Feb 2026 14:05:18 -0500 Subject: [PATCH] feat: add regolith onboard CLI wizard with daemon installation - Interactive setup wizard: deps check, env config, WhatsApp auth, daemon install, health check - CLI entry point via yargs with bin registration (regolith onboard) - Flags: --install-daemon, --non-interactive, --pairing-code, --json, --skip-* - launchd (macOS) and systemd (Linux) service installation - Refactored whatsapp-auth.ts to export authenticate() for programmatic use - 72 tests across 6 test files - Updated README.md and CLAUDE.md with onboard CLI docs --- CLAUDE.md | 17 ++ README.md | 39 ++- package-lock.json | 311 ++++++++++++++++++------ package.json | 5 + src/cli/display.test.ts | 109 +++++++++ src/cli/display.ts | 47 ++++ src/cli/index.ts | 69 ++++++ src/cli/steps/check-deps.test.ts | 181 ++++++++++++++ src/cli/steps/check-deps.ts | 115 +++++++++ src/cli/steps/configure-env.test.ts | 279 ++++++++++++++++++++++ src/cli/steps/configure-env.ts | 193 +++++++++++++++ src/cli/steps/health-check.test.ts | 317 +++++++++++++++++++++++++ src/cli/steps/health-check.ts | 221 +++++++++++++++++ src/cli/steps/install-daemon.test.ts | 343 +++++++++++++++++++++++++++ src/cli/steps/install-daemon.ts | 273 +++++++++++++++++++++ src/cli/steps/whatsapp-auth.test.ts | 186 +++++++++++++++ src/cli/steps/whatsapp-auth.ts | 122 ++++++++++ src/cli/types.ts | 31 +++ src/cli/wizard-runner.ts | 72 ++++++ src/whatsapp-auth.ts | 215 +++++++++-------- 20 files changed, 2981 insertions(+), 164 deletions(-) create mode 100644 src/cli/display.test.ts create mode 100644 src/cli/display.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/steps/check-deps.test.ts create mode 100644 src/cli/steps/check-deps.ts create mode 100644 src/cli/steps/configure-env.test.ts create mode 100644 src/cli/steps/configure-env.ts create mode 100644 src/cli/steps/health-check.test.ts create mode 100644 src/cli/steps/health-check.ts create mode 100644 src/cli/steps/install-daemon.test.ts create mode 100644 src/cli/steps/install-daemon.ts create mode 100644 src/cli/steps/whatsapp-auth.test.ts create mode 100644 src/cli/steps/whatsapp-auth.ts create mode 100644 src/cli/types.ts create mode 100644 src/cli/wizard-runner.ts diff --git a/CLAUDE.md b/CLAUDE.md index 85f3d16..16cb256 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,11 @@ Single Node.js process that connects to WhatsApp and/or Discord, routes messages | File | Purpose | |------|---------| +| `src/cli/index.ts` | CLI entry point (`regolith onboard`) | +| `src/cli/wizard-runner.ts` | Onboard wizard step orchestrator | +| `src/cli/steps/*.ts` | Individual wizard steps (deps, env, whatsapp, daemon, health) | +| `src/cli/display.ts` | Progress indicators and summary output | +| `src/cli/types.ts` | Wizard types (WizardFlags, StepResult, WizardContext) | | `src/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation | | `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | | `src/channels/discord.ts` | Discord bot connection, mention handling, attachments, reply context | @@ -32,11 +37,22 @@ Single Node.js process that connects to WhatsApp and/or Discord, routes messages | `ASSISTANT_NAME` | Andy | Trigger word for the bot | | `DISCORD_BOT_TOKEN` | (empty) | Discord bot token; set to enable Discord | | `DISCORD_ONLY` | false | Skip WhatsApp when true | +| `AGENT_BACKEND` | container | Agent backend: "container" or "opencode" | | `OPENCODE_MODE` | cli | OpenCode mode: "cli" or "sdk" | | `OPENCODE_MODEL` | (unset) | Model name for OpenCode | | `OPENCODE_TIMEOUT` | 120 | Timeout in seconds | | `OPENCODE_SESSION_TTL_HOURS` | 24 | Session TTL in hours | +## Onboard CLI + +```bash +regolith onboard # Interactive setup wizard +regolith onboard --install-daemon # Include daemon installation +regolith onboard --non-interactive # Use defaults, no prompts +regolith onboard --json # Output JSON summary +regolith onboard --skip-deps --skip-whatsapp # Skip specific steps +``` + ## Skills | Skill | When to Use | @@ -52,4 +68,5 @@ npm run dev # Run with hot reload npm run build # Compile TypeScript npm test # Run tests npm run typecheck # Type check without emitting +regolith onboard # Run the setup wizard ``` diff --git a/README.md b/README.md index 607075f..cc4a11c 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,39 @@ The core philosophy remains the same: small enough to understand, secure by cont git clone http://10.0.0.59:3051/tanmay/Regolith.git cd Regolith/nanoclaw npm install +npm run build +regolith onboard ``` +The onboard wizard walks you through everything: dependency checks, `.env` configuration, WhatsApp authentication, and optional daemon installation. + +To install as a background service in one shot: +```bash +regolith onboard --install-daemon +``` + +For non-interactive setup (CI/scripting): +```bash +regolith onboard --non-interactive --install-daemon +``` + +### Onboard CLI Flags + +| Flag | Description | +|------|-------------| +| `--install-daemon` | Install as launchd (macOS) or systemd (Linux) service | +| `--non-interactive` | Run without prompts, use defaults | +| `--pairing-code` | Use pairing code instead of QR for WhatsApp | +| `--json` | Output JSON summary instead of text | +| `--skip-deps` | Skip dependency checks | +| `--skip-env` | Skip .env configuration | +| `--skip-whatsapp` | Skip WhatsApp authentication | +| `--skip-health` | Skip health check | + +### Manual Setup + +If you prefer to configure manually instead of using the wizard: + Configure your `.env`: ```bash # WhatsApp (enabled by default) @@ -38,7 +69,10 @@ ASSISTANT_NAME=Andy DISCORD_BOT_TOKEN=your-discord-bot-token DISCORD_ONLY=false # set to true to disable WhatsApp -# OpenCode runtime (optional) +# Agent backend: "container" (Claude Agent SDK) or "opencode" +AGENT_BACKEND=container + +# OpenCode runtime (optional, used when AGENT_BACKEND=opencode) OPENCODE_MODE=cli # or "sdk" OPENCODE_MODEL=claude # model name OPENCODE_TIMEOUT=120 # seconds @@ -72,6 +106,9 @@ The `findChannel(channels, jid)` function resolves which channel owns a given JI | File | Purpose | |------|---------| +| `src/cli/index.ts` | CLI entry point (`regolith onboard`) | +| `src/cli/wizard-runner.ts` | Onboard wizard step orchestrator | +| `src/cli/steps/*.ts` | Individual wizard steps (deps, env, whatsapp, daemon, health) | | `src/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation | | `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | | `src/channels/discord.ts` | Discord bot connection, mention handling, attachment tagging | diff --git a/package-lock.json b/package-lock.json index 81048d0..a47de64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "nanoclaw", - "version": "1.0.0", + "name": "regolith", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw", - "version": "1.0.0", + "name": "regolith", + "version": "2.0.0", "dependencies": { "@whiskeysockets/baileys": "^7.0.0-rc.9", "better-sqlite3": "^11.8.1", @@ -17,12 +17,17 @@ "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "yaml": "^2.8.2", + "yargs": "^18.0.0", "zod": "^4.3.6" }, + "bin": { + "regolith": "dist/cli/index.js" + }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", "@types/qrcode-terminal": "^0.12.2", + "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", "fast-check": "^4.5.3", "prettier": "^3.8.1", @@ -1827,6 +1832,23 @@ "@types/node": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -2019,24 +2041,24 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2196,14 +2218,17 @@ "license": "ISC" }, "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/color-convert": { @@ -2368,9 +2393,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/end-of-stream": { @@ -2431,6 +2456,15 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2592,6 +2626,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -3390,6 +3436,128 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -3725,29 +3893,35 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-json-comments": { @@ -4164,17 +4338,20 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -4205,10 +4382,13 @@ } }, "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/yaml": { "version": "2.8.2", @@ -4226,38 +4406,29 @@ } }, "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/zod": { diff --git a/package.json b/package.json index c7b8ff2..d68d40b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Personal AI assistant with multi-channel support (WhatsApp, Discord) and multi-runtime backends (Claude Agent SDK, OpenCode).", "type": "module", "main": "dist/index.js", + "bin": { + "regolith": "dist/cli/index.js" + }, "scripts": { "build": "tsc", "start": "node dist/index.js", @@ -25,12 +28,14 @@ "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "yaml": "^2.8.2", + "yargs": "^18.0.0", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", "@types/qrcode-terminal": "^0.12.2", + "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "^4.0.18", "fast-check": "^4.5.3", "prettier": "^3.8.1", diff --git a/src/cli/display.test.ts b/src/cli/display.test.ts new file mode 100644 index 0000000..3b78ad2 --- /dev/null +++ b/src/cli/display.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { stepHeader, stepSuccess, stepWarning, stepError, printSummary } from './display.js'; +import type { StepResult } from './types.js'; + +describe('display', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + describe('stepHeader', () => { + it('outputs [current/total] label format', () => { + stepHeader(2, 5, 'Checking dependencies...'); + expect(logSpy).toHaveBeenCalledWith('[2/5] Checking dependencies...'); + }); + + it('handles step 1 of 1', () => { + stepHeader(1, 1, 'Only step'); + expect(logSpy).toHaveBeenCalledWith('[1/1] Only step'); + }); + }); + + describe('stepSuccess', () => { + it('outputs ✓ indicator with message', () => { + stepSuccess('Node.js v22.0.0 detected'); + expect(logSpy).toHaveBeenCalledWith(' ✓ Node.js v22.0.0 detected'); + }); + }); + + describe('stepWarning', () => { + it('outputs ⚠ indicator with message', () => { + stepWarning('No container runtime found'); + expect(logSpy).toHaveBeenCalledWith(' ⚠ No container runtime found'); + }); + }); + + describe('stepError', () => { + it('outputs ✗ indicator with message', () => { + stepError('Node.js version too old'); + expect(logSpy).toHaveBeenCalledWith(' ✗ Node.js version too old'); + }); + }); + + describe('printSummary', () => { + const results: StepResult[] = [ + { name: 'Dependency Check', status: 'passed', message: 'All good' }, + { name: 'Env Config', status: 'failed', message: 'Missing token' }, + { name: 'WhatsApp Auth', status: 'skipped', message: 'Skipped' }, + { name: 'Health Check', status: 'warning', message: 'Partial' }, + ]; + + describe('text mode', () => { + it('prints summary header and each result with correct indicator', () => { + printSummary(results, false); + expect(logSpy).toHaveBeenCalledWith('\nSummary:'); + expect(logSpy).toHaveBeenCalledWith(' ✓ Dependency Check: All good'); + expect(logSpy).toHaveBeenCalledWith(' ✗ Env Config: Missing token'); + expect(logSpy).toHaveBeenCalledWith(' ⊘ WhatsApp Auth: Skipped'); + expect(logSpy).toHaveBeenCalledWith(' ⚠ Health Check: Partial'); + }); + }); + + describe('JSON mode', () => { + it('outputs valid JSON with success and steps', () => { + printSummary(results, true); + const output = logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.success).toBe(false); + expect(parsed.steps).toHaveLength(4); + expect(parsed.steps[0]).toEqual({ name: 'Dependency Check', status: 'passed', message: 'All good' }); + expect(parsed.steps[1]).toEqual({ name: 'Env Config', status: 'failed', message: 'Missing token' }); + }); + + it('sets success to true when no failures', () => { + const passing: StepResult[] = [ + { name: 'Check', status: 'passed', message: 'OK' }, + { name: 'Warn', status: 'warning', message: 'Hmm' }, + { name: 'Skip', status: 'skipped', message: 'Nope' }, + ]; + printSummary(passing, true); + const parsed = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(parsed.success).toBe(true); + }); + + it('includes details when present', () => { + const withDetails: StepResult[] = [ + { name: 'Check', status: 'passed', message: 'OK', details: { version: '22.0.0' } }, + ]; + printSummary(withDetails, true); + const parsed = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(parsed.steps[0].details).toEqual({ version: '22.0.0' }); + }); + + it('omits details when not present', () => { + const noDetails: StepResult[] = [ + { name: 'Check', status: 'passed', message: 'OK' }, + ]; + printSummary(noDetails, true); + const parsed = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(parsed.steps[0]).not.toHaveProperty('details'); + }); + }); + }); +}); diff --git a/src/cli/display.ts b/src/cli/display.ts new file mode 100644 index 0000000..d9a97fa --- /dev/null +++ b/src/cli/display.ts @@ -0,0 +1,47 @@ +import type { StepResult } from './types.js'; + +const STATUS_INDICATORS: Record = { + passed: '✓', + failed: '✗', + warning: '⚠', + skipped: '⊘', +}; + +export function stepHeader(current: number, total: number, label: string): void { + console.log(`[${current}/${total}] ${label}`); +} + +export function stepSuccess(message: string): void { + console.log(` ✓ ${message}`); +} + +export function stepWarning(message: string): void { + console.log(` ⚠ ${message}`); +} + +export function stepError(message: string): void { + console.log(` ✗ ${message}`); +} + +export function printSummary(results: StepResult[], jsonMode: boolean): void { + if (jsonMode) { + const success = results.every((r) => r.status !== 'failed'); + const output = { + success, + steps: results.map((r) => ({ + name: r.name, + status: r.status, + message: r.message, + ...(r.details ? { details: r.details } : {}), + })), + }; + console.log(JSON.stringify(output)); + return; + } + + console.log('\nSummary:'); + for (const result of results) { + const indicator = STATUS_INDICATORS[result.status]; + console.log(` ${indicator} ${result.name}: ${result.message}`); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..6a37356 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { runWizard } from './wizard-runner.js'; + +yargs(hideBin(process.argv)) + .command( + 'onboard', + 'Interactive setup wizard', + (yargs) => { + return yargs + .option('install-daemon', { + type: 'boolean', + default: false, + describe: 'Install as background service', + }) + .option('non-interactive', { + type: 'boolean', + default: false, + describe: 'Run without prompts', + }) + .option('pairing-code', { + type: 'boolean', + default: false, + describe: 'Use pairing code for WhatsApp', + }) + .option('json', { + type: 'boolean', + default: false, + describe: 'Output JSON summary', + }) + .option('skip-deps', { + type: 'boolean', + default: false, + describe: 'Skip dependency checks', + }) + .option('skip-env', { + type: 'boolean', + default: false, + describe: 'Skip env configuration', + }) + .option('skip-whatsapp', { + type: 'boolean', + default: false, + describe: 'Skip WhatsApp auth', + }) + .option('skip-health', { + type: 'boolean', + default: false, + describe: 'Skip health check', + }); + }, + async (argv) => { + const code = await runWizard({ + installDaemon: argv['install-daemon'] as boolean, + nonInteractive: argv['non-interactive'] as boolean, + pairingCode: argv['pairing-code'] as boolean, + json: argv.json as boolean, + skipDeps: argv['skip-deps'] as boolean, + skipEnv: argv['skip-env'] as boolean, + skipWhatsapp: argv['skip-whatsapp'] as boolean, + skipHealth: argv['skip-health'] as boolean, + }); + process.exit(code); + }, + ) + .demandCommand(1) + .help() + .parse(); diff --git a/src/cli/steps/check-deps.test.ts b/src/cli/steps/check-deps.test.ts new file mode 100644 index 0000000..298605d --- /dev/null +++ b/src/cli/steps/check-deps.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseNodeMajor } from './check-deps.js'; +import type { WizardContext } from '../types.js'; + +// Mock child_process before importing checkDeps +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +import { execFile as execFileCb } from 'child_process'; +import { checkDeps } from './check-deps.js'; + +function makeCtx(overrides: Partial = {}): WizardContext { + return { + flags: { + installDaemon: false, + nonInteractive: false, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + projectRoot: '/test', + platform: 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + ...overrides, + }; +} + +/** + * Helper to mock execFile calls. Maps command+args to stdout or error. + */ +function mockExecFile(mapping: Record) { + const mock = execFileCb as unknown as ReturnType; + mock.mockImplementation( + (cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void) => { + // promisify passes (cmd, args, opts) — the callback is added by promisify + const key = `${cmd} ${(args || []).join(' ')}`.trim(); + const result = mapping[key]; + if (cb) { + if (result instanceof Error) { + cb(result, { stdout: '', stderr: '' }); + } else if (result !== undefined) { + cb(null, { stdout: result, stderr: '' }); + } else { + cb(new Error(`not found: ${key}`), { stdout: '', stderr: '' }); + } + } + }, + ); +} + +describe('parseNodeMajor', () => { + it('parses v20.11.0 as 20', () => { + expect(parseNodeMajor('v20.11.0')).toBe(20); + }); + + it('parses v22.0.0 as 22', () => { + expect(parseNodeMajor('v22.0.0')).toBe(22); + }); + + it('parses v18.19.1 as 18', () => { + expect(parseNodeMajor('v18.19.1')).toBe(18); + }); + + it('parses without v prefix', () => { + expect(parseNodeMajor('21.5.0')).toBe(21); + }); + + it('returns NaN for garbage input', () => { + expect(parseNodeMajor('not-a-version')).toBeNaN(); + }); +}); + +describe('checkDeps', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.restoreAllMocks(); + // Re-mock child_process after restoreAllMocks + const mock = execFileCb as unknown as ReturnType; + mock.mockReset(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('fails when Node version is below 20', async () => { + // Override process.version for this test + const originalVersion = process.version; + Object.defineProperty(process, 'version', { value: 'v18.19.1', configurable: true }); + + try { + const ctx = makeCtx(); + const result = await checkDeps(ctx); + expect(result.status).toBe('failed'); + expect(result.message).toContain('v18.19.1'); + expect(result.message).toContain('>=20'); + } finally { + Object.defineProperty(process, 'version', { value: originalVersion, configurable: true }); + } + }); + + it('detects docker on Linux', async () => { + mockExecFile({ + 'which docker': '/usr/bin/docker\n', + 'docker --version': 'Docker version 24.0.7, build afdd53b\n', + }); + + const ctx = makeCtx({ platform: 'linux' }); + const result = await checkDeps(ctx); + + expect(result.status).toBe('passed'); + expect(ctx.containerRuntime).toBe('docker'); + expect(ctx.containerVersion).toContain('Docker version'); + }); + + it('detects apple-container on macOS', async () => { + mockExecFile({ + 'which container': '/usr/local/bin/container\n', + 'container --version': 'container 1.0.0\n', + }); + + const ctx = makeCtx({ platform: 'darwin' }); + const result = await checkDeps(ctx); + + expect(result.status).toBe('passed'); + expect(ctx.containerRuntime).toBe('apple-container'); + expect(ctx.containerVersion).toContain('container 1.0.0'); + }); + + it('falls back to docker on macOS when container is not found', async () => { + mockExecFile({ + 'which container': new Error('not found'), + 'which docker': '/usr/local/bin/docker\n', + 'docker --version': 'Docker version 25.0.0\n', + }); + + const ctx = makeCtx({ platform: 'darwin' }); + const result = await checkDeps(ctx); + + expect(result.status).toBe('passed'); + expect(ctx.containerRuntime).toBe('docker'); + }); + + it('returns warning when no container runtime is found on Linux', async () => { + mockExecFile({ + 'which docker': new Error('not found'), + }); + + const ctx = makeCtx({ platform: 'linux' }); + const result = await checkDeps(ctx); + + expect(result.status).toBe('warning'); + expect(result.message).toContain('docker'); + expect(ctx.containerRuntime).toBeNull(); + }); + + it('returns warning when no container runtime is found on macOS', async () => { + mockExecFile({ + 'which container': new Error('not found'), + 'which docker': new Error('not found'), + }); + + const ctx = makeCtx({ platform: 'darwin' }); + const result = await checkDeps(ctx); + + expect(result.status).toBe('warning'); + expect(result.message).toContain('container, docker'); + expect(ctx.containerRuntime).toBeNull(); + }); +}); diff --git a/src/cli/steps/check-deps.ts b/src/cli/steps/check-deps.ts new file mode 100644 index 0000000..d492a8e --- /dev/null +++ b/src/cli/steps/check-deps.ts @@ -0,0 +1,115 @@ +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; +import type { WizardContext, StepResult } from '../types.js'; +import { stepSuccess, stepWarning, stepError } from '../display.js'; + +const execFile = promisify(execFileCb); + +const EXEC_TIMEOUT = 10_000; + +/** + * Parse the major version number from a Node.js version string like "v20.11.0". + */ +export function parseNodeMajor(version: string): number { + const match = version.match(/^v?(\d+)/); + return match ? parseInt(match[1], 10) : NaN; +} + +/** + * Check if a binary exists in PATH using `which`. + */ +async function whichBinary(name: string): Promise { + try { + const { stdout } = await execFile('which', [name], { timeout: EXEC_TIMEOUT }); + const path = stdout.trim(); + return path || null; + } catch { + return null; + } +} + +/** + * Run a version command and return the trimmed stdout. + */ +async function getVersionOutput(binary: string, args: string[]): Promise { + try { + const { stdout } = await execFile(binary, args, { timeout: EXEC_TIMEOUT }); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Dependency check step: validates Node version and detects container runtime. + */ +export async function checkDeps(ctx: WizardContext): Promise { + // --- Node version check --- + const currentVersion = process.version; + const major = parseNodeMajor(currentVersion); + + if (isNaN(major) || major < 20) { + const msg = `Node.js ${currentVersion} does not satisfy required >=20`; + stepError(msg); + return { + name: 'Dependency Check', + status: 'failed', + message: msg, + details: { nodeVersion: currentVersion, required: '>=20' }, + }; + } + + stepSuccess(`Node.js ${currentVersion} (satisfies >=20)`); + + // --- Container runtime detection --- + let runtimeName: WizardContext['containerRuntime'] = null; + let runtimeVersion: string | null = null; + + if (ctx.platform === 'darwin') { + // macOS: try Apple Container first + const containerPath = await whichBinary('container'); + if (containerPath) { + runtimeName = 'apple-container'; + runtimeVersion = await getVersionOutput('container', ['--version']); + } + } + + // Fallback to docker (or primary on Linux) + if (!runtimeName) { + const dockerPath = await whichBinary('docker'); + if (dockerPath) { + runtimeName = 'docker'; + runtimeVersion = await getVersionOutput('docker', ['--version']); + } + } + + ctx.containerRuntime = runtimeName; + ctx.containerVersion = runtimeVersion; + + if (!runtimeName) { + const checked = ctx.platform === 'darwin' + ? 'container, docker' + : 'docker'; + const msg = `No container runtime found (checked: ${checked})`; + stepWarning(msg); + return { + name: 'Dependency Check', + status: 'warning', + message: msg, + details: { nodeVersion: currentVersion, containerRuntime: null, checked }, + }; + } + + stepSuccess(`Container runtime: ${runtimeName} (${runtimeVersion ?? 'unknown version'})`); + + return { + name: 'Dependency Check', + status: 'passed', + message: `Node.js ${currentVersion}, runtime: ${runtimeName}`, + details: { + nodeVersion: currentVersion, + containerRuntime: runtimeName, + containerVersion: runtimeVersion, + }, + }; +} diff --git a/src/cli/steps/configure-env.test.ts b/src/cli/steps/configure-env.test.ts new file mode 100644 index 0000000..eac3810 --- /dev/null +++ b/src/cli/steps/configure-env.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { + MANAGED_KEYS, + validateDiscordToken, + writeEnvFile, + configureEnv, +} from './configure-env.js'; +import type { WizardContext } from '../types.js'; + +// Mock readEnvFile from env.ts +vi.mock('../../env.js', () => ({ + readEnvFile: vi.fn(() => ({})), +})); + +import { readEnvFile } from '../../env.js'; + +function makeCtx(overrides: Partial = {}): WizardContext { + return { + flags: { + installDaemon: false, + nonInteractive: false, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + projectRoot: '/tmp/test-env-step', + platform: 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + ...overrides, + }; +} + +describe('MANAGED_KEYS', () => { + it('contains all expected keys with correct defaults', () => { + expect(MANAGED_KEYS.ASSISTANT_NAME).toBe('Andy'); + expect(MANAGED_KEYS.DISCORD_BOT_TOKEN).toBe(''); + expect(MANAGED_KEYS.DISCORD_ONLY).toBe('false'); + expect(MANAGED_KEYS.AGENT_BACKEND).toBe('container'); + expect(MANAGED_KEYS.OPENCODE_MODE).toBe('cli'); + expect(MANAGED_KEYS.OPENCODE_MODEL).toBe(''); + expect(MANAGED_KEYS.OPENCODE_TIMEOUT).toBe('120'); + expect(MANAGED_KEYS.OPENCODE_SESSION_TTL_HOURS).toBe('24'); + }); +}); + +describe('validateDiscordToken', () => { + it('returns error when DISCORD_ONLY is true and token is empty', () => { + const result = validateDiscordToken({ DISCORD_ONLY: 'true', DISCORD_BOT_TOKEN: '' }); + expect(result).toBeTruthy(); + expect(result).toContain('DISCORD_BOT_TOKEN'); + }); + + it('returns null when DISCORD_ONLY is true and token is provided', () => { + expect(validateDiscordToken({ DISCORD_ONLY: 'true', DISCORD_BOT_TOKEN: 'abc123' })).toBeNull(); + }); + + it('returns null when DISCORD_ONLY is false and token is empty', () => { + expect(validateDiscordToken({ DISCORD_ONLY: 'false', DISCORD_BOT_TOKEN: '' })).toBeNull(); + }); + + it('returns null when DISCORD_ONLY is false and token is provided', () => { + expect(validateDiscordToken({ DISCORD_ONLY: 'false', DISCORD_BOT_TOKEN: 'abc' })).toBeNull(); + }); +}); + +describe('writeEnvFile', () => { + const tmpDir = path.join('/tmp', 'test-writeenv-' + process.pid); + let envPath: string; + + beforeEach(() => { + fs.mkdirSync(tmpDir, { recursive: true }); + envPath = path.join(tmpDir, '.env'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a new file with managed keys', () => { + writeEnvFile(envPath, { FOO: 'bar', BAZ: 'qux' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('FOO=bar'); + expect(content).toContain('BAZ=qux'); + expect(content.endsWith('\n')).toBe(true); + }); + + it('preserves unmanaged keys', () => { + fs.writeFileSync(envPath, 'UNMANAGED=keep\nFOO=old\n', 'utf-8'); + writeEnvFile(envPath, { FOO: 'new' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('UNMANAGED=keep'); + expect(content).toContain('FOO=new'); + expect(content).not.toContain('FOO=old'); + }); + + it('preserves comments', () => { + fs.writeFileSync(envPath, '# This is a comment\nFOO=old\n', 'utf-8'); + writeEnvFile(envPath, { FOO: 'new' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('# This is a comment'); + expect(content).toContain('FOO=new'); + }); + + it('preserves blank lines', () => { + fs.writeFileSync(envPath, 'A=1\n\nB=2\n', 'utf-8'); + writeEnvFile(envPath, { A: 'x' }); + const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); + // Should still have a blank line between entries + expect(lines).toContain(''); + }); + + it('appends new managed keys not already in file', () => { + fs.writeFileSync(envPath, 'EXISTING=val\n', 'utf-8'); + writeEnvFile(envPath, { NEW_KEY: 'hello' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('EXISTING=val'); + expect(content).toContain('NEW_KEY=hello'); + }); + + it('handles empty existing file', () => { + fs.writeFileSync(envPath, '', 'utf-8'); + writeEnvFile(envPath, { KEY: 'val' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('KEY=val'); + }); + + it('handles file with only comments', () => { + fs.writeFileSync(envPath, '# comment 1\n# comment 2\n', 'utf-8'); + writeEnvFile(envPath, { KEY: 'val' }); + const content = fs.readFileSync(envPath, 'utf-8'); + expect(content).toContain('# comment 1'); + expect(content).toContain('# comment 2'); + expect(content).toContain('KEY=val'); + }); +}); + +describe('configureEnv', () => { + let logSpy: ReturnType; + const tmpDir = path.join('/tmp', 'test-configenv-' + process.pid); + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + fs.mkdirSync(tmpDir, { recursive: true }); + vi.mocked(readEnvFile).mockReturnValue({}); + }); + + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('uses defaults in non-interactive mode with no existing env', async () => { + const ctx = makeCtx({ + projectRoot: tmpDir, + flags: { + installDaemon: false, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await configureEnv(ctx); + expect(result.status).toBe('passed'); + expect(ctx.envValues.ASSISTANT_NAME).toBe('Andy'); + expect(ctx.envValues.AGENT_BACKEND).toBe('container'); + expect(ctx.envValues.DISCORD_ONLY).toBe('false'); + + // Verify file was written + const content = fs.readFileSync(path.join(tmpDir, '.env'), 'utf-8'); + expect(content).toContain('ASSISTANT_NAME=Andy'); + }); + + it('merges existing values in non-interactive mode', async () => { + // Write an existing .env + fs.writeFileSync(path.join(tmpDir, '.env'), 'CUSTOM=keep\n', 'utf-8'); + vi.mocked(readEnvFile).mockReturnValue({ ASSISTANT_NAME: 'Bob' }); + + const ctx = makeCtx({ + projectRoot: tmpDir, + flags: { + installDaemon: false, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await configureEnv(ctx); + expect(result.status).toBe('passed'); + expect(ctx.envValues.ASSISTANT_NAME).toBe('Bob'); + + const content = fs.readFileSync(path.join(tmpDir, '.env'), 'utf-8'); + expect(content).toContain('CUSTOM=keep'); + expect(content).toContain('ASSISTANT_NAME=Bob'); + }); + + it('prefers process.env over existing file in non-interactive mode', async () => { + vi.mocked(readEnvFile).mockReturnValue({ ASSISTANT_NAME: 'Bob' }); + const originalEnv = process.env.ASSISTANT_NAME; + process.env.ASSISTANT_NAME = 'Charlie'; + + try { + const ctx = makeCtx({ + projectRoot: tmpDir, + flags: { + installDaemon: false, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await configureEnv(ctx); + expect(result.status).toBe('passed'); + expect(ctx.envValues.ASSISTANT_NAME).toBe('Charlie'); + } finally { + if (originalEnv === undefined) { + delete process.env.ASSISTANT_NAME; + } else { + process.env.ASSISTANT_NAME = originalEnv; + } + } + }); + + it('fails validation when DISCORD_ONLY=true and token is empty', async () => { + // Simulate non-interactive with DISCORD_ONLY=true from process.env + const origDiscordOnly = process.env.DISCORD_ONLY; + process.env.DISCORD_ONLY = 'true'; + + try { + const ctx = makeCtx({ + projectRoot: tmpDir, + flags: { + installDaemon: false, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await configureEnv(ctx); + expect(result.status).toBe('failed'); + expect(result.message).toContain('DISCORD_BOT_TOKEN'); + } finally { + if (origDiscordOnly === undefined) { + delete process.env.DISCORD_ONLY; + } else { + process.env.DISCORD_ONLY = origDiscordOnly; + } + } + }); +}); diff --git a/src/cli/steps/configure-env.ts b/src/cli/steps/configure-env.ts new file mode 100644 index 0000000..677dce6 --- /dev/null +++ b/src/cli/steps/configure-env.ts @@ -0,0 +1,193 @@ +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import type { WizardContext, StepResult } from '../types.js'; +import { stepSuccess, stepWarning, stepError } from '../display.js'; +import { readEnvFile } from '../../env.js'; + +/** + * Managed keys and their default values. + */ +export const MANAGED_KEYS: Record = { + ASSISTANT_NAME: 'Andy', + DISCORD_BOT_TOKEN: '', + DISCORD_ONLY: 'false', + AGENT_BACKEND: 'container', + OPENCODE_MODE: 'cli', + OPENCODE_MODEL: '', + OPENCODE_TIMEOUT: '120', + OPENCODE_SESSION_TTL_HOURS: '24', +}; + +/** + * Prompt the user for a value using readline, showing a default. + */ +function prompt(question: string, defaultValue: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const display = defaultValue ? ` [${defaultValue}]` : ''; + return new Promise((resolve) => { + rl.question(`${question}${display}: `, (answer) => { + rl.close(); + resolve(answer.trim() || defaultValue); + }); + }); +} + +/** + * Validate that DISCORD_BOT_TOKEN is non-empty when DISCORD_ONLY is "true". + */ +export function validateDiscordToken(values: Record): string | null { + if (values.DISCORD_ONLY === 'true' && !values.DISCORD_BOT_TOKEN) { + return 'DISCORD_BOT_TOKEN must be non-empty when DISCORD_ONLY is "true"'; + } + return null; +} + +/** + * Write env values to the .env file, preserving unmanaged keys and comments. + * + * Algorithm: + * 1. Read existing file lines (if any). + * 2. Walk each line: if it sets a managed key, replace the value; otherwise keep the line as-is. + * 3. Append any managed keys that weren't already present. + */ +export function writeEnvFile( + filePath: string, + managedValues: Record, +): void { + let existingLines: string[] = []; + try { + existingLines = fs.readFileSync(filePath, 'utf-8').split('\n'); + } catch { + // File doesn't exist yet — start fresh. + } + + const managedKeySet = new Set(Object.keys(managedValues)); + const written = new Set(); + const outputLines: string[] = []; + + for (const line of existingLines) { + const trimmed = line.trim(); + // Preserve comments and blank lines. + if (!trimmed || trimmed.startsWith('#')) { + outputLines.push(line); + continue; + } + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) { + // Not a key=value line — preserve as-is. + outputLines.push(line); + continue; + } + const key = trimmed.slice(0, eqIdx).trim(); + if (managedKeySet.has(key)) { + // Replace with new value. + outputLines.push(`${key}=${managedValues[key]}`); + written.add(key); + } else { + // Unmanaged key — preserve. + outputLines.push(line); + } + } + + // Append managed keys that weren't already in the file. + for (const key of Object.keys(managedValues)) { + if (!written.has(key)) { + outputLines.push(`${key}=${managedValues[key]}`); + } + } + + // Ensure file ends with a newline. + const content = outputLines.join('\n').replace(/\n*$/, '\n'); + fs.writeFileSync(filePath, content, 'utf-8'); +} + +/** + * Environment configuration wizard step. + */ +export async function configureEnv(ctx: WizardContext): Promise { + const envPath = path.join(ctx.projectRoot, '.env'); + const envExists = fs.existsSync(envPath); + + // Read existing values for managed keys. + const existingValues = readEnvFile(Object.keys(MANAGED_KEYS)); + + // Build values: start with defaults, layer existing, then collect input. + const values: Record = { ...MANAGED_KEYS }; + + if (ctx.flags.nonInteractive) { + // Non-interactive: merge defaults ← existing env file ← process.env. + for (const key of Object.keys(MANAGED_KEYS)) { + if (existingValues[key] !== undefined) { + values[key] = existingValues[key]; + } + if (process.env[key] !== undefined) { + values[key] = process.env[key]!; + } + } + + // In non-interactive mode with existing file, always merge. + } else { + // Interactive: handle existing file conflict. + if (envExists) { + const action = await prompt( + '.env already exists. (o)verwrite or (m)erge?', + 'm', + ); + if (action.toLowerCase().startsWith('o')) { + // Overwrite: ignore existing values. + } else { + // Merge: pre-fill with existing values. + for (const key of Object.keys(MANAGED_KEYS)) { + if (existingValues[key] !== undefined) { + values[key] = existingValues[key]; + } + } + } + } + + // Prompt for each managed key. + for (const key of Object.keys(MANAGED_KEYS)) { + values[key] = await prompt(key, values[key]); + } + } + + // Validate Discord token requirement. + const validationError = validateDiscordToken(values); + if (validationError) { + stepError(validationError); + return { + name: 'Environment Configuration', + status: 'failed', + message: validationError, + details: { values }, + }; + } + + // Write the file. + try { + writeEnvFile(envPath, values); + } catch (err) { + const msg = `Failed to write .env: ${err instanceof Error ? err.message : String(err)}`; + stepError(msg); + return { + name: 'Environment Configuration', + status: 'failed', + message: msg, + }; + } + + // Store values in context for later steps. + ctx.envValues = values; + + stepSuccess('Environment configured'); + return { + name: 'Environment Configuration', + status: 'passed', + message: 'Environment configured', + details: { values }, + }; +} diff --git a/src/cli/steps/health-check.test.ts b/src/cli/steps/health-check.test.ts new file mode 100644 index 0000000..a2e28e1 --- /dev/null +++ b/src/cli/steps/health-check.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { WizardContext } from '../types.js'; + +// Mock child_process and fs before importing healthCheck +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), + }; +}); + +import fs from 'fs'; +import { execFile as execFileCb } from 'child_process'; +import { healthCheck } from './health-check.js'; + +function makeCtx(overrides: Partial = {}): WizardContext { + return { + flags: { + installDaemon: false, + nonInteractive: false, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + projectRoot: '/test', + platform: 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + ...overrides, + }; +} + +function mockExecFile(mapping: Record) { + const mock = execFileCb as unknown as ReturnType; + mock.mockImplementation( + (cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void) => { + const key = `${cmd} ${(args || []).join(' ')}`.trim(); + const result = mapping[key]; + if (cb) { + if (result instanceof Error) { + cb(result, { stdout: '', stderr: '' }); + } else if (result !== undefined) { + cb(null, { stdout: result, stderr: '' }); + } else { + cb(new Error(`not found: ${key}`), { stdout: '', stderr: '' }); + } + } + }, + ); +} + +describe('healthCheck', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(fs.existsSync).mockReset(); + vi.mocked(fs.readFileSync).mockReset(); + vi.mocked(fs.readdirSync).mockReset(); + const mock = execFileCb as unknown as ReturnType; + mock.mockReset(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('passes when .env has required keys and runtime is accessible', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('passed'); + expect(result.message).toContain('2/2'); + }); + + it('fails when .env file is missing', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'env-file')?.passed).toBe(false); + }); + + it('fails when .env is missing required keys', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('ASSISTANT_NAME=Andy\n'); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean; message: string }>); + const envCheck = checks.find((c) => c.name === 'env-file'); + expect(envCheck?.passed).toBe(false); + expect(envCheck?.message).toContain('AGENT_BACKEND'); + }); + + it('fails when container runtime is not accessible', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': new Error('command not found'), + }); + + const ctx = makeCtx({ containerRuntime: 'docker' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'container-runtime')?.passed).toBe(false); + }); + + it('fails when no container runtime was detected', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + + const ctx = makeCtx({ containerRuntime: null }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'container-runtime')?.passed).toBe(false); + }); + + it('checks WhatsApp auth dir when whatsappAuthed is true', async () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p).replace(/\\/g, '/'); + if (s.endsWith('.env')) return true; + if (s.includes('store/auth')) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + vi.mocked(fs.readdirSync).mockReturnValue(['creds.json'] as unknown as ReturnType); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', whatsappAuthed: true }); + const result = await healthCheck(ctx); + + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'whatsapp-auth')?.passed).toBe(true); + expect(result.status).toBe('passed'); + }); + + it('fails WhatsApp check when auth dir is empty', async () => { + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p).replace(/\\/g, '/'); + if (s.endsWith('.env')) return true; + if (s.includes('store/auth')) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', whatsappAuthed: true }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'whatsapp-auth')?.passed).toBe(false); + }); + + it('skips WhatsApp check when whatsappAuthed is false', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', whatsappAuthed: false }); + const result = await healthCheck(ctx); + + const checks = (result.details?.checks as Array<{ name: string }>); + expect(checks.find((c) => c.name === 'whatsapp-auth')).toBeUndefined(); + }); + + it('checks daemon status on Linux when daemonInstalled is true', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + 'systemctl --user is-active regolith.service': 'active\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', daemonInstalled: true, platform: 'linux' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('passed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'daemon')?.passed).toBe(true); + }); + + it('checks daemon status on macOS when daemonInstalled is true', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'container --version': 'container 1.0.0\n', + 'launchctl list': '123\t0\tcom.nanoclaw\n', + }); + + const ctx = makeCtx({ containerRuntime: 'apple-container', daemonInstalled: true, platform: 'darwin' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('passed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'daemon')?.passed).toBe(true); + }); + + it('fails daemon check when systemd reports inactive', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + 'systemctl --user is-active regolith.service': 'inactive\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', daemonInstalled: true, platform: 'linux' }); + const result = await healthCheck(ctx); + + expect(result.status).toBe('failed'); + const checks = (result.details?.checks as Array<{ name: string; passed: boolean }>); + expect(checks.find((c) => c.name === 'daemon')?.passed).toBe(false); + }); + + it('skips daemon check when daemonInstalled is false', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker', daemonInstalled: false }); + const result = await healthCheck(ctx); + + const checks = (result.details?.checks as Array<{ name: string }>); + expect(checks.find((c) => c.name === 'daemon')).toBeUndefined(); + }); + + it('returns composite details with all sub-check results', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'ASSISTANT_NAME=Andy\nAGENT_BACKEND=container\n', + ); + mockExecFile({ + 'docker --version': 'Docker version 24.0.7\n', + }); + + const ctx = makeCtx({ containerRuntime: 'docker' }); + const result = await healthCheck(ctx); + + expect(result.details).toBeDefined(); + const checks = result.details?.checks as Array<{ name: string; passed: boolean; message: string }>; + expect(checks).toHaveLength(2); + expect(checks[0]).toHaveProperty('name'); + expect(checks[0]).toHaveProperty('passed'); + expect(checks[0]).toHaveProperty('message'); + }); +}); diff --git a/src/cli/steps/health-check.ts b/src/cli/steps/health-check.ts new file mode 100644 index 0000000..a4a55e2 --- /dev/null +++ b/src/cli/steps/health-check.ts @@ -0,0 +1,221 @@ +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs'; +import path from 'path'; +import type { WizardContext, StepResult } from '../types.js'; +import { stepSuccess, stepWarning, stepError } from '../display.js'; + +const execFile = promisify(execFileCb); +const EXEC_TIMEOUT = 10_000; +const STEP_NAME = 'Health Check'; + +const REQUIRED_ENV_KEYS = ['ASSISTANT_NAME', 'AGENT_BACKEND']; + +interface SubCheck { + name: string; + passed: boolean; + message: string; +} + +/** + * Verify .env exists and contains required keys. + */ +function checkEnvFile(projectRoot: string): SubCheck { + const envPath = path.join(projectRoot, '.env'); + + if (!fs.existsSync(envPath)) { + return { name: 'env-file', passed: false, message: '.env file not found' }; + } + + const content = fs.readFileSync(envPath, 'utf-8'); + const presentKeys = new Set(); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + if (value) presentKeys.add(key); + } + + const missing = REQUIRED_ENV_KEYS.filter((k) => !presentKeys.has(k)); + if (missing.length > 0) { + return { + name: 'env-file', + passed: false, + message: `Missing required keys: ${missing.join(', ')}`, + }; + } + + return { name: 'env-file', passed: true, message: '.env contains required keys' }; +} + +/** + * Verify container runtime is accessible by running its version command. + */ +async function checkContainerRuntime(ctx: WizardContext): Promise { + if (!ctx.containerRuntime) { + return { + name: 'container-runtime', + passed: false, + message: 'No container runtime detected', + }; + } + + const binary = ctx.containerRuntime === 'apple-container' ? 'container' : 'docker'; + try { + const { stdout } = await execFile(binary, ['--version'], { timeout: EXEC_TIMEOUT }); + return { + name: 'container-runtime', + passed: true, + message: `${ctx.containerRuntime} accessible (${stdout.trim()})`, + }; + } catch { + return { + name: 'container-runtime', + passed: false, + message: `${ctx.containerRuntime} not responding to version command`, + }; + } +} + +/** + * Verify WhatsApp auth credentials exist if WhatsApp was authenticated. + */ +function checkWhatsAppAuth(ctx: WizardContext): SubCheck | null { + if (!ctx.whatsappAuthed) return null; + + const authDir = path.join(ctx.projectRoot, 'store', 'auth'); + + if (!fs.existsSync(authDir)) { + return { + name: 'whatsapp-auth', + passed: false, + message: 'store/auth/ directory not found', + }; + } + + const entries = fs.readdirSync(authDir); + if (entries.length === 0) { + return { + name: 'whatsapp-auth', + passed: false, + message: 'store/auth/ is empty — no credential files found', + }; + } + + return { + name: 'whatsapp-auth', + passed: true, + message: `WhatsApp credentials present (${entries.length} file${entries.length === 1 ? '' : 's'})`, + }; +} + +/** + * Verify daemon service is running via platform-specific status command. + */ +async function checkDaemonStatus(ctx: WizardContext): Promise { + if (!ctx.daemonInstalled) return null; + + try { + if (ctx.platform === 'darwin') { + const { stdout } = await execFile('launchctl', ['list'], { timeout: EXEC_TIMEOUT }); + if (stdout.includes('com.nanoclaw')) { + return { name: 'daemon', passed: true, message: 'Daemon running (launchd)' }; + } + return { name: 'daemon', passed: false, message: 'Daemon not found in launchctl list' }; + } + + // Linux: systemd + const { stdout } = await execFile( + 'systemctl', + ['--user', 'is-active', 'regolith.service'], + { timeout: EXEC_TIMEOUT }, + ); + if (stdout.trim() === 'active') { + return { name: 'daemon', passed: true, message: 'Daemon running (systemd)' }; + } + return { + name: 'daemon', + passed: false, + message: `Daemon status: ${stdout.trim()}`, + }; + } catch { + return { + name: 'daemon', + passed: false, + message: 'Failed to check daemon status', + }; + } +} + +/** + * Health check wizard step. + * + * Runs multiple sub-checks and aggregates them into a composite result: + * - .env exists with required keys + * - Container runtime accessible + * - WhatsApp credentials present (if authed) + * - Daemon running (if installed) + * + * Overall status is 'passed' if all sub-checks pass, 'failed' if any fail. + */ +export async function healthCheck(ctx: WizardContext): Promise { + const checks: SubCheck[] = []; + + // 1. Env file check + checks.push(checkEnvFile(ctx.projectRoot)); + + // 2. Container runtime check + checks.push(await checkContainerRuntime(ctx)); + + // 3. WhatsApp auth check (conditional) + const waCheck = checkWhatsAppAuth(ctx); + if (waCheck) checks.push(waCheck); + + // 4. Daemon status check (conditional) + const daemonCheck = await checkDaemonStatus(ctx); + if (daemonCheck) checks.push(daemonCheck); + + // Display individual results + for (const check of checks) { + if (check.passed) { + stepSuccess(check.message); + } else { + stepError(check.message); + } + } + + const allPassed = checks.every((c) => c.passed); + const failedChecks = checks.filter((c) => !c.passed); + + if (!allPassed) { + // Display remediation guidance for failed checks + for (const check of failedChecks) { + if (check.name === 'env-file') { + stepWarning('Run the env configuration step or create .env manually'); + } else if (check.name === 'container-runtime') { + stepWarning('Install Docker or Apple Container and ensure it is running'); + } else if (check.name === 'whatsapp-auth') { + stepWarning('Re-run WhatsApp authentication or check store/auth/ directory'); + } else if (check.name === 'daemon') { + stepWarning('Check service logs or reinstall the daemon'); + } + } + } + + const passed = checks.filter((c) => c.passed).length; + const total = checks.length; + const summary = `${passed}/${total} checks passed`; + + return { + name: STEP_NAME, + status: allPassed ? 'passed' : 'failed', + message: summary, + details: { + checks: checks.map((c) => ({ name: c.name, passed: c.passed, message: c.message })), + }, + }; +} diff --git a/src/cli/steps/install-daemon.test.ts b/src/cli/steps/install-daemon.test.ts new file mode 100644 index 0000000..c09fc5c --- /dev/null +++ b/src/cli/steps/install-daemon.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { substituteTemplate } from './install-daemon.js'; +import type { WizardContext } from '../types.js'; + +// Mock child_process and fs before importing installDaemon +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +import { execFile as execFileCb } from 'child_process'; +import fs from 'fs/promises'; +import { installDaemon } from './install-daemon.js'; + +function makeCtx(overrides: Partial = {}): WizardContext { + return { + flags: { + installDaemon: false, + nonInteractive: false, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + projectRoot: '/test/project', + platform: 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + ...overrides, + }; +} + +/** + * Normalize path separators to forward slashes for cross-platform mock matching. + */ +function normalizePath(s: string): string { + return s.replace(/\\/g, '/'); +} + +/** + * Helper to mock execFile calls. Maps command+args to stdout or error. + * Normalizes path separators so tests work on Windows too. + */ +function mockExecFile(mapping: Record) { + const mock = execFileCb as unknown as ReturnType; + mock.mockImplementation( + (cmd: string, args: string[], _opts: unknown, cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void) => { + const key = normalizePath(`${cmd} ${(args || []).join(' ')}`.trim()); + // Try normalized key against normalized mapping keys + let result: string | Error | undefined; + for (const [mapKey, mapVal] of Object.entries(mapping)) { + if (normalizePath(mapKey) === key) { + result = mapVal; + break; + } + } + if (cb) { + if (result instanceof Error) { + cb(result, { stdout: '', stderr: '' }); + } else if (result !== undefined) { + cb(null, { stdout: result, stderr: '' }); + } else { + cb(new Error(`not found: ${key}`), { stdout: '', stderr: '' }); + } + } + }, + ); +} + +describe('substituteTemplate', () => { + it('replaces all placeholders with values', () => { + const template = 'Hello {{NAME}}, welcome to {{PLACE}}!'; + const result = substituteTemplate(template, { NAME: 'Alice', PLACE: 'Wonderland' }); + expect(result).toBe('Hello Alice, welcome to Wonderland!'); + }); + + it('replaces multiple occurrences of the same placeholder', () => { + const template = '{{X}} and {{X}} again'; + const result = substituteTemplate(template, { X: 'foo' }); + expect(result).toBe('foo and foo again'); + }); + + it('leaves template unchanged when no matching keys', () => { + const template = 'No {{MATCH}} here'; + const result = substituteTemplate(template, { OTHER: 'value' }); + expect(result).toBe('No {{MATCH}} here'); + }); + + it('handles empty values', () => { + const template = 'Path: {{HOME}}/.config'; + const result = substituteTemplate(template, { HOME: '' }); + expect(result).toBe('Path: /.config'); + }); + + it('handles paths with spaces and special characters', () => { + const template = 'WorkingDirectory={{PROJECT_ROOT}}'; + const result = substituteTemplate(template, { PROJECT_ROOT: '/home/user/my project (v2)' }); + expect(result).toBe('WorkingDirectory=/home/user/my project (v2)'); + }); +}); + +describe('installDaemon', () => { + let logSpy: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const mock = execFileCb as unknown as ReturnType; + mock.mockReset(); + (fs.readFile as ReturnType).mockReset(); + (fs.writeFile as ReturnType).mockReset(); + (fs.mkdir as ReturnType).mockReset(); + (fs.mkdir as ReturnType).mockResolvedValue(undefined); + (fs.writeFile as ReturnType).mockResolvedValue(undefined); + process.env.HOME = '/home/testuser'; + }); + + afterEach(() => { + logSpy.mockRestore(); + process.env = { ...originalEnv }; + }); + + it('skips when --install-daemon not set and non-interactive', async () => { + const ctx = makeCtx({ + flags: { + installDaemon: false, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('skipped'); + expect(result.message).toContain('--install-daemon not set'); + expect(ctx.daemonInstalled).toBe(false); + }); + + it('installs systemd service on Linux', async () => { + mockExecFile({ + 'which node': '/usr/bin/node\n', + 'systemctl --user enable --now regolith.service': '', + 'systemctl --user is-active regolith.service': 'active\n', + }); + + const ctx = makeCtx({ + platform: 'linux', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('passed'); + expect(result.message).toContain('systemd'); + expect(ctx.daemonInstalled).toBe(true); + + // Verify the unit file was written + const writeCall = (fs.writeFile as ReturnType).mock.calls[0]; + expect(writeCall[0]).toContain('regolith.service'); + const unitContent = writeCall[1] as string; + expect(unitContent).toContain('/usr/bin/node'); + expect(unitContent).toContain('/test/project'); + expect(unitContent).not.toContain('{{'); + }); + + it('installs launchd service on macOS', async () => { + const plistTemplate = '{{NODE_PATH}} {{PROJECT_ROOT}} {{HOME}}'; + (fs.readFile as ReturnType).mockResolvedValue(plistTemplate); + + mockExecFile({ + 'which node': '/usr/local/bin/node\n', + 'launchctl load /home/testuser/Library/LaunchAgents/com.nanoclaw.plist': '', + 'launchctl list': 'com.nanoclaw\t0\tcom.nanoclaw\n', + }); + + const ctx = makeCtx({ + platform: 'darwin', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('passed'); + expect(result.message).toContain('launchd'); + expect(ctx.daemonInstalled).toBe(true); + + // Verify the plist was written with substituted values + const writeCall = (fs.writeFile as ReturnType).mock.calls[0]; + expect(writeCall[0]).toContain('com.nanoclaw.plist'); + const plistContent = writeCall[1] as string; + expect(plistContent).toContain('/usr/local/bin/node'); + expect(plistContent).toContain('/test/project'); + expect(plistContent).not.toContain('{{'); + }); + + it('returns failed with manual instructions on launchd error', async () => { + (fs.readFile as ReturnType).mockResolvedValue('{{NODE_PATH}}'); + + mockExecFile({ + 'which node': '/usr/local/bin/node\n', + 'launchctl load /home/testuser/Library/LaunchAgents/com.nanoclaw.plist': new Error('permission denied'), + }); + + const ctx = makeCtx({ + platform: 'darwin', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('failed'); + expect(result.message).toContain('permission denied'); + expect(ctx.daemonInstalled).toBe(false); + }); + + it('returns failed with manual instructions on systemd error', async () => { + mockExecFile({ + 'which node': '/usr/bin/node\n', + 'systemctl --user enable --now regolith.service': new Error('Failed to connect to bus'), + }); + + const ctx = makeCtx({ + platform: 'linux', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('failed'); + expect(result.message).toContain('Failed to connect to bus'); + expect(ctx.daemonInstalled).toBe(false); + }); + + it('uses fallback plist template when file not found on macOS', async () => { + (fs.readFile as ReturnType).mockRejectedValue(new Error('ENOENT')); + + mockExecFile({ + 'which node': '/usr/local/bin/node\n', + 'launchctl load /home/testuser/Library/LaunchAgents/com.nanoclaw.plist': '', + 'launchctl list': 'com.nanoclaw\n', + }); + + const ctx = makeCtx({ + platform: 'darwin', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('passed'); + + // Verify the fallback template was used and substituted + const writeCall = (fs.writeFile as ReturnType).mock.calls[0]; + const plistContent = writeCall[1] as string; + expect(plistContent).toContain('com.nanoclaw'); + expect(plistContent).toContain('/usr/local/bin/node'); + expect(plistContent).not.toContain('{{'); + }); + + it('returns warning when systemd service is not active after install', async () => { + mockExecFile({ + 'which node': '/usr/bin/node\n', + 'systemctl --user enable --now regolith.service': '', + 'systemctl --user is-active regolith.service': 'activating\n', + }); + + const ctx = makeCtx({ + platform: 'linux', + flags: { + installDaemon: true, + nonInteractive: true, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + }); + + const result = await installDaemon(ctx); + expect(result.status).toBe('warning'); + expect(result.message).toContain('activating'); + expect(ctx.daemonInstalled).toBe(false); + }); +}); diff --git a/src/cli/steps/install-daemon.ts b/src/cli/steps/install-daemon.ts new file mode 100644 index 0000000..e97d5ec --- /dev/null +++ b/src/cli/steps/install-daemon.ts @@ -0,0 +1,273 @@ +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs/promises'; +import path from 'path'; +import readline from 'readline'; +import type { WizardContext, StepResult } from '../types.js'; +import { stepSuccess, stepError, stepWarning } from '../display.js'; + +const execFile = promisify(execFileCb); +const EXEC_TIMEOUT = 10_000; +const STEP_NAME = 'Daemon Install'; + +const SYSTEMD_TEMPLATE = `[Unit] +Description=Regolith AI Assistant +After=network.target + +[Service] +Type=simple +WorkingDirectory={{PROJECT_ROOT}} +ExecStart={{NODE_PATH}} {{PROJECT_ROOT}}/dist/index.js +Restart=on-failure +Environment=HOME={{HOME}} +Environment=PATH={{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=default.target +`; + +/** + * Replace all `{{KEY}}` placeholders in a template with the corresponding values. + */ +export function substituteTemplate(template: string, values: Record): string { + let result = template; + for (const [key, value] of Object.entries(values)) { + result = result.replaceAll(`{{${key}}}`, value); + } + return result; +} + +/** + * Prompt the user with a yes/no question via readline. + */ +function confirm(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${question} (y/n): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase().startsWith('y')); + }); + }); +} + +/** + * Resolve the path to the current Node.js binary. + */ +async function resolveNodePath(): Promise { + try { + const { stdout } = await execFile('which', ['node'], { timeout: EXEC_TIMEOUT }); + return stdout.trim(); + } catch { + return process.execPath; + } +} + +/** + * Read the launchd plist template from the project's launchd directory. + * Falls back to an inline template if the file doesn't exist. + */ +async function readPlistTemplate(projectRoot: string): Promise { + const templatePath = path.join(projectRoot, 'launchd', 'com.nanoclaw.plist'); + try { + return await fs.readFile(templatePath, 'utf-8'); + } catch { + // Fallback inline plist template + return ` + + + + Label + com.nanoclaw + ProgramArguments + + {{NODE_PATH}} + {{PROJECT_ROOT}}/dist/index.js + + WorkingDirectory + {{PROJECT_ROOT}} + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + {{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin + HOME + {{HOME}} + + StandardOutPath + {{PROJECT_ROOT}}/logs/nanoclaw.log + StandardErrorPath + {{PROJECT_ROOT}}/logs/nanoclaw.error.log + +`; + } +} + +/** + * Install the daemon on macOS using launchd. + */ +async function installLaunchd(ctx: WizardContext, values: Record): Promise { + const home = values.HOME; + const plistDir = path.join(home, 'Library', 'LaunchAgents'); + const plistPath = path.join(plistDir, 'com.nanoclaw.plist'); + + try { + // Read and substitute the plist template + const template = await readPlistTemplate(ctx.projectRoot); + const plistContent = substituteTemplate(template, values); + + // Ensure the LaunchAgents directory exists + await fs.mkdir(plistDir, { recursive: true }); + + // Write the plist file + await fs.writeFile(plistPath, plistContent, 'utf-8'); + + // Load the service + await execFile('launchctl', ['load', plistPath], { timeout: EXEC_TIMEOUT }); + + // Verify the service is loaded + const { stdout } = await execFile('launchctl', ['list'], { timeout: EXEC_TIMEOUT }); + if (!stdout.includes('com.nanoclaw')) { + stepWarning('Service loaded but not found in launchctl list'); + return { + name: STEP_NAME, + status: 'warning', + message: 'Service loaded but could not verify via launchctl list', + details: { platform: 'darwin', plistPath }, + }; + } + + ctx.daemonInstalled = true; + stepSuccess(`Daemon installed at ${plistPath}`); + return { + name: STEP_NAME, + status: 'passed', + message: `Daemon installed and running (launchd)`, + details: { platform: 'darwin', plistPath }, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + stepError(`Failed to install launchd service: ${errorMsg}`); + stepWarning('Manual installation instructions:'); + stepWarning(` 1. Copy the plist to ${plistPath}`); + stepWarning(` 2. Run: launchctl load ${plistPath}`); + stepWarning(` 3. Verify: launchctl list | grep com.nanoclaw`); + return { + name: STEP_NAME, + status: 'failed', + message: `Failed to install launchd service: ${errorMsg}`, + details: { platform: 'darwin', error: errorMsg }, + }; + } +} + +/** + * Install the daemon on Linux using systemd. + */ +async function installSystemd(ctx: WizardContext, values: Record): Promise { + const home = values.HOME; + const unitDir = path.join(home, '.config', 'systemd', 'user'); + const unitPath = path.join(unitDir, 'regolith.service'); + + try { + // Generate the systemd unit from the inline template + const unitContent = substituteTemplate(SYSTEMD_TEMPLATE, values); + + // Ensure the systemd user directory exists + await fs.mkdir(unitDir, { recursive: true }); + + // Write the unit file + await fs.writeFile(unitPath, unitContent, 'utf-8'); + + // Enable and start the service + await execFile('systemctl', ['--user', 'enable', '--now', 'regolith.service'], { timeout: EXEC_TIMEOUT }); + + // Verify the service is active + const { stdout } = await execFile('systemctl', ['--user', 'is-active', 'regolith.service'], { timeout: EXEC_TIMEOUT }); + if (stdout.trim() !== 'active') { + stepWarning(`Service enabled but status is: ${stdout.trim()}`); + return { + name: STEP_NAME, + status: 'warning', + message: `Service enabled but status is: ${stdout.trim()}`, + details: { platform: 'linux', unitPath, status: stdout.trim() }, + }; + } + + ctx.daemonInstalled = true; + stepSuccess(`Daemon installed at ${unitPath}`); + return { + name: STEP_NAME, + status: 'passed', + message: 'Daemon installed and running (systemd)', + details: { platform: 'linux', unitPath }, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + stepError(`Failed to install systemd service: ${errorMsg}`); + stepWarning('Manual installation instructions:'); + stepWarning(` 1. Copy the unit file to ${unitPath}`); + stepWarning(` 2. Run: systemctl --user enable --now regolith.service`); + stepWarning(` 3. Verify: systemctl --user is-active regolith.service`); + return { + name: STEP_NAME, + status: 'failed', + message: `Failed to install systemd service: ${errorMsg}`, + details: { platform: 'linux', error: errorMsg }, + }; + } +} + +/** + * Daemon installation wizard step. + * + * - Runs if `--install-daemon` flag is set, or user confirms in interactive mode. + * - On macOS: installs via launchd plist. + * - On Linux: installs via systemd user unit. + * - On failure: displays error and manual installation instructions. + * - Sets `ctx.daemonInstalled` on success. + */ +export async function installDaemon(ctx: WizardContext): Promise { + // Determine whether to proceed with installation + if (!ctx.flags.installDaemon) { + if (ctx.flags.nonInteractive) { + stepWarning('Skipped (--install-daemon not set)'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped (--install-daemon not set)', + }; + } + + const proceed = await confirm('Install Regolith as a background service?'); + if (!proceed) { + stepWarning('Skipped by user'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped by user', + }; + } + } + + // Resolve template values + const nodePath = await resolveNodePath(); + const home = process.env.HOME ?? ''; + const values: Record = { + NODE_PATH: nodePath, + PROJECT_ROOT: ctx.projectRoot, + HOME: home, + }; + + // Install based on platform + if (ctx.platform === 'darwin') { + return installLaunchd(ctx, values); + } + + return installSystemd(ctx, values); +} diff --git a/src/cli/steps/whatsapp-auth.test.ts b/src/cli/steps/whatsapp-auth.test.ts new file mode 100644 index 0000000..60336a0 --- /dev/null +++ b/src/cli/steps/whatsapp-auth.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { WizardContext } from '../types.js'; + +// Mock whatsapp-auth module before importing the step +vi.mock('../../whatsapp-auth.js', () => ({ + authenticate: vi.fn(), +})); + +// Mock readline to avoid actual stdin prompts +vi.mock('readline', () => ({ + default: { + createInterface: vi.fn(() => ({ + question: vi.fn(), + close: vi.fn(), + })), + }, +})); + +import readline from 'readline'; +import { authenticate } from '../../whatsapp-auth.js'; +import { whatsappAuth } from './whatsapp-auth.js'; + +function makeCtx(overrides: Partial = {}): WizardContext { + return { + flags: { + installDaemon: false, + nonInteractive: false, + pairingCode: false, + json: false, + skipDeps: false, + skipEnv: false, + skipWhatsapp: false, + skipHealth: false, + }, + projectRoot: '/test', + platform: 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + ...overrides, + }; +} + +/** + * Helper to mock readline responses in sequence. + */ +function mockReadlineAnswers(answers: string[]) { + let callIndex = 0; + const mockCreateInterface = readline.createInterface as unknown as ReturnType; + mockCreateInterface.mockImplementation(() => ({ + question: vi.fn((_q: string, cb: (answer: string) => void) => { + cb(answers[callIndex++] ?? ''); + }), + close: vi.fn(), + })); +} + +describe('whatsappAuth', () => { + let logSpy: ReturnType; + const mockAuthenticate = authenticate as unknown as ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockAuthenticate.mockReset(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('skips when DISCORD_ONLY is "true"', async () => { + const ctx = makeCtx({ envValues: { DISCORD_ONLY: 'true' } }); + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('DISCORD_ONLY'); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('skips when --skip-whatsapp flag is set', async () => { + const ctx = makeCtx(); + ctx.flags.skipWhatsapp = true; + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('--skip-whatsapp'); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('DISCORD_ONLY takes precedence over skipWhatsapp', async () => { + const ctx = makeCtx({ envValues: { DISCORD_ONLY: 'true' } }); + ctx.flags.skipWhatsapp = true; + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('DISCORD_ONLY'); + }); + + it('skips when user declines in interactive mode', async () => { + mockReadlineAnswers(['n']); + const ctx = makeCtx(); + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('Skipped by user'); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('succeeds when authenticate returns success', async () => { + mockReadlineAnswers(['y']); + mockAuthenticate.mockResolvedValue({ success: true }); + + const ctx = makeCtx(); + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('passed'); + expect(ctx.whatsappAuthed).toBe(true); + expect(mockAuthenticate).toHaveBeenCalledWith({ pairingCode: false }); + }); + + it('passes pairingCode flag to authenticate', async () => { + mockReadlineAnswers(['y']); + mockAuthenticate.mockResolvedValue({ success: true }); + + const ctx = makeCtx(); + ctx.flags.pairingCode = true; + await whatsappAuth(ctx); + + expect(mockAuthenticate).toHaveBeenCalledWith({ pairingCode: true }); + }); + + it('returns failed in non-interactive mode on auth failure', async () => { + mockAuthenticate.mockResolvedValue({ success: false, error: 'QR code timed out.' }); + + const ctx = makeCtx(); + ctx.flags.nonInteractive = true; + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('failed'); + expect(result.message).toContain('QR code timed out.'); + expect(ctx.whatsappAuthed).toBe(false); + }); + + it('allows skip after failure in interactive mode', async () => { + // First answer: confirm setup (y), then after failure: skip (s) + mockReadlineAnswers(['y', 's']); + mockAuthenticate.mockResolvedValue({ success: false, error: 'Connection failed' }); + + const ctx = makeCtx(); + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('skipped'); + expect(result.message).toContain('Skipped after failure'); + expect(ctx.whatsappAuthed).toBe(false); + }); + + it('retries after failure in interactive mode', async () => { + // First answer: confirm setup (y), then retry (r), then succeed + mockReadlineAnswers(['y', 'r']); + mockAuthenticate + .mockResolvedValueOnce({ success: false, error: 'Connection failed' }) + .mockResolvedValueOnce({ success: true }); + + const ctx = makeCtx(); + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('passed'); + expect(ctx.whatsappAuthed).toBe(true); + expect(mockAuthenticate).toHaveBeenCalledTimes(2); + }); + + it('does not prompt for confirmation in non-interactive mode', async () => { + mockAuthenticate.mockResolvedValue({ success: true }); + + const ctx = makeCtx(); + ctx.flags.nonInteractive = true; + const result = await whatsappAuth(ctx); + + expect(result.status).toBe('passed'); + // authenticate should be called directly without readline confirm + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/steps/whatsapp-auth.ts b/src/cli/steps/whatsapp-auth.ts new file mode 100644 index 0000000..62fcd88 --- /dev/null +++ b/src/cli/steps/whatsapp-auth.ts @@ -0,0 +1,122 @@ +import readline from 'readline'; +import type { WizardContext, StepResult } from '../types.js'; +import { stepSuccess, stepWarning, stepError } from '../display.js'; +import { authenticate } from '../../whatsapp-auth.js'; + +const STEP_NAME = 'WhatsApp Auth'; + +/** + * Prompt the user with a yes/no question via readline. + */ +function confirm(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${question} (y/n): `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase().startsWith('y')); + }); + }); +} + +/** + * Prompt the user to choose retry or skip after a failure. + * Returns true to retry, false to skip. + */ +function promptRetryOrSkip(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(' (r)etry or (s)kip? ', (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase().startsWith('r')); + }); + }); +} + +/** + * WhatsApp authentication wizard step. + * + * - Skipped when DISCORD_ONLY=true or --skip-whatsapp flag is set. + * - In interactive mode, prompts user to confirm before starting auth. + * - Calls authenticate() with pairing code option from flags. + * - On failure: offers retry/skip (interactive) or returns failed (non-interactive). + */ +export async function whatsappAuth(ctx: WizardContext): Promise { + // Skip if DISCORD_ONLY is enabled + if (ctx.envValues.DISCORD_ONLY === 'true') { + stepWarning('Skipped (DISCORD_ONLY is enabled)'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped (DISCORD_ONLY is enabled)', + }; + } + + // Skip if --skip-whatsapp flag is set + if (ctx.flags.skipWhatsapp) { + stepWarning('Skipped (--skip-whatsapp)'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped (--skip-whatsapp)', + }; + } + + // In interactive mode, ask user to confirm + if (!ctx.flags.nonInteractive) { + const proceed = await confirm('Set up WhatsApp authentication?'); + if (!proceed) { + stepWarning('Skipped by user'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped by user', + }; + } + } + + // Attempt authentication (with retry loop for interactive mode) + while (true) { + const result = await authenticate({ pairingCode: ctx.flags.pairingCode }); + + if (result.success) { + ctx.whatsappAuthed = true; + stepSuccess('WhatsApp authenticated'); + return { + name: STEP_NAME, + status: 'passed', + message: 'WhatsApp authenticated', + }; + } + + // Auth failed + const errorMsg = result.error ?? 'Unknown error'; + stepError(`WhatsApp auth failed: ${errorMsg}`); + + if (ctx.flags.nonInteractive) { + return { + name: STEP_NAME, + status: 'failed', + message: `WhatsApp auth failed: ${errorMsg}`, + details: { error: errorMsg }, + }; + } + + // Interactive: offer retry or skip + const retry = await promptRetryOrSkip(); + if (!retry) { + stepWarning('Skipped after failure'); + return { + name: STEP_NAME, + status: 'skipped', + message: 'Skipped after failure', + }; + } + // Loop back to retry authenticate() + } +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..f627455 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,31 @@ +export interface WizardFlags { + installDaemon: boolean; + nonInteractive: boolean; + pairingCode: boolean; + json: boolean; + skipDeps: boolean; + skipEnv: boolean; + skipWhatsapp: boolean; + skipHealth: boolean; +} + +export interface StepResult { + name: string; + status: 'passed' | 'failed' | 'skipped' | 'warning'; + message: string; + details?: Record; +} + +export interface WizardContext { + flags: WizardFlags; + projectRoot: string; + platform: 'darwin' | 'linux'; + envValues: Record; + containerRuntime: 'apple-container' | 'docker' | null; + containerVersion: string | null; + whatsappAuthed: boolean; + daemonInstalled: boolean; + results: StepResult[]; +} + +export type WizardStep = (ctx: WizardContext) => Promise; diff --git a/src/cli/wizard-runner.ts b/src/cli/wizard-runner.ts new file mode 100644 index 0000000..22b7a72 --- /dev/null +++ b/src/cli/wizard-runner.ts @@ -0,0 +1,72 @@ +import type { WizardFlags, WizardContext, StepResult } from './types.js'; +import { stepHeader, stepSuccess, stepWarning, stepError, printSummary } from './display.js'; +import { checkDeps } from './steps/check-deps.js'; +import { configureEnv } from './steps/configure-env.js'; +import { whatsappAuth } from './steps/whatsapp-auth.js'; +import { installDaemon } from './steps/install-daemon.js'; +import { healthCheck } from './steps/health-check.js'; + +interface StepDefinition { + name: string; + skipFlag: keyof WizardFlags | null; + run: (ctx: WizardContext) => Promise; +} + +const STEPS: StepDefinition[] = [ + { name: 'Checking dependencies', skipFlag: 'skipDeps', run: checkDeps }, + { name: 'Configuring environment', skipFlag: 'skipEnv', run: configureEnv }, + { name: 'WhatsApp authentication', skipFlag: 'skipWhatsapp', run: whatsappAuth }, + { name: 'Installing daemon', skipFlag: null, run: installDaemon }, + { name: 'Running health check', skipFlag: 'skipHealth', run: healthCheck }, +]; + +export async function runWizard(flags: WizardFlags): Promise { + const ctx: WizardContext = { + flags, + projectRoot: process.cwd(), + platform: process.platform === 'darwin' ? 'darwin' : 'linux', + envValues: {}, + containerRuntime: null, + containerVersion: null, + whatsappAuthed: false, + daemonInstalled: false, + results: [], + }; + + // Determine which steps are active (not skipped via flags) + const activeSteps = STEPS.filter( + (step) => !step.skipFlag || !flags[step.skipFlag], + ); + const total = activeSteps.length; + + let stepIndex = 0; + + for (const step of STEPS) { + const skipped = step.skipFlag && flags[step.skipFlag]; + + if (skipped) { + ctx.results.push({ + name: step.name, + status: 'skipped', + message: `Skipped (--${step.skipFlag!.replace(/([A-Z])/g, '-$1').toLowerCase()})`, + }); + continue; + } + + stepIndex++; + stepHeader(stepIndex, total, step.name); + + const result = await step.run(ctx); + ctx.results.push(result); + + // Fatal: Node version check failed — exit immediately + if (step.run === checkDeps && result.status === 'failed') { + return 1; + } + } + + printSummary(ctx.results, flags.json); + + const hasFailed = ctx.results.some((r) => r.status === 'failed'); + return hasFailed ? 1 : 0; +} diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index a969835..b7af396 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -1,10 +1,11 @@ /** - * WhatsApp Authentication Script + * WhatsApp Authentication Module * - * Run this during setup to authenticate with WhatsApp. - * Displays QR code, waits for scan, saves credentials, then exits. + * Exports an `authenticate()` function for programmatic use (e.g. from the onboard wizard). + * Also works as a standalone script via `npm run auth` / `npx tsx src/whatsapp-auth.ts`. * - * Usage: npx tsx src/whatsapp-auth.ts + * Usage (standalone): npx tsx src/whatsapp-auth.ts [--pairing-code] [--phone ] + * Usage (import): import { authenticate } from './whatsapp-auth.js'; */ import fs from 'fs'; import path from 'path'; @@ -27,9 +28,15 @@ const logger = pino({ level: 'warn', // Quiet logging - only show errors }); -// Check for --pairing-code flag and phone number -const usePairingCode = process.argv.includes('--pairing-code'); -const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); +export interface WhatsAppAuthOptions { + pairingCode?: boolean; + phoneNumber?: string; +} + +export interface WhatsAppAuthResult { + success: boolean; + error?: string; +} function askQuestion(prompt: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -41,117 +48,139 @@ function askQuestion(prompt: string): Promise { }); } -async function connectSocket(phoneNumber?: string, isReconnect = false): Promise { - const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); +function connectSocket(phoneNumber?: string, usePairingCode?: boolean, isReconnect = false): Promise { + return new Promise(async (resolve) => { + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); - if (state.creds.registered && !isReconnect) { - fs.writeFileSync(STATUS_FILE, 'already_authenticated'); - console.log('✓ Already authenticated with WhatsApp'); - console.log( - ' To re-authenticate, delete the store/auth folder and run again.', - ); - process.exit(0); - } + if (state.creds.registered && !isReconnect) { + fs.writeFileSync(STATUS_FILE, 'already_authenticated'); + console.log('✓ Already authenticated with WhatsApp'); + console.log( + ' To re-authenticate, delete the store/auth folder and run again.', + ); + resolve({ success: true }); + return; + } - const sock = makeWASocket({ - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); + const sock = makeWASocket({ + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + printQRInTerminal: false, + logger, + browser: Browsers.macOS('Chrome'), + }); - if (usePairingCode && phoneNumber && !state.creds.me) { - // Request pairing code after a short delay for connection to initialize - // Only on first connect (not reconnect after 515) - setTimeout(async () => { - try { - const code = await sock.requestPairingCode(phoneNumber!); - console.log(`\n🔗 Your pairing code: ${code}\n`); + if (usePairingCode && phoneNumber && !state.creds.me) { + // Request pairing code after a short delay for connection to initialize + // Only on first connect (not reconnect after 515) + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phoneNumber!); + console.log(`\n🔗 Your pairing code: ${code}\n`); + console.log(' 1. Open WhatsApp on your phone'); + console.log(' 2. Tap Settings → Linked Devices → Link a Device'); + console.log(' 3. Tap "Link with phone number instead"'); + console.log(` 4. Enter this code: ${code}\n`); + fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); + } catch (err: any) { + console.error('Failed to request pairing code:', err.message); + resolve({ success: false, error: `Failed to request pairing code: ${err.message}` }); + } + }, 3000); + } + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + // Write raw QR data to file so the setup skill can render it + fs.writeFileSync(QR_FILE, qr); + console.log('Scan this QR code with WhatsApp:\n'); console.log(' 1. Open WhatsApp on your phone'); console.log(' 2. Tap Settings → Linked Devices → Link a Device'); - console.log(' 3. Tap "Link with phone number instead"'); - console.log(` 4. Enter this code: ${code}\n`); - fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); - } catch (err: any) { - console.error('Failed to request pairing code:', err.message); - process.exit(1); + console.log(' 3. Point your camera at the QR code below\n'); + qrcode.generate(qr, { small: true }); } - }, 3000); - } - sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; + if (connection === 'close') { + const reason = (lastDisconnect?.error as any)?.output?.statusCode; - if (qr) { - // Write raw QR data to file so the setup skill can render it - fs.writeFileSync(QR_FILE, qr); - console.log('Scan this QR code with WhatsApp:\n'); - console.log(' 1. Open WhatsApp on your phone'); - console.log(' 2. Tap Settings → Linked Devices → Link a Device'); - console.log(' 3. Point your camera at the QR code below\n'); - qrcode.generate(qr, { small: true }); - } - - if (connection === 'close') { - const reason = (lastDisconnect?.error as any)?.output?.statusCode; - - if (reason === DisconnectReason.loggedOut) { - fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); - console.log('\n✗ Logged out. Delete store/auth and try again.'); - process.exit(1); - } else if (reason === DisconnectReason.timedOut) { - fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); - console.log('\n✗ QR code timed out. Please try again.'); - process.exit(1); - } else if (reason === 515) { - // 515 = stream error, often happens after pairing succeeds but before - // registration completes. Reconnect to finish the handshake. - console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); - connectSocket(phoneNumber, true); - } else { - fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); - console.log('\n✗ Connection failed. Please try again.'); - process.exit(1); + if (reason === DisconnectReason.loggedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); + console.log('\n✗ Logged out. Delete store/auth and try again.'); + resolve({ success: false, error: 'Logged out. Delete store/auth and try again.' }); + } else if (reason === DisconnectReason.timedOut) { + fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); + console.log('\n✗ QR code timed out. Please try again.'); + resolve({ success: false, error: 'QR code timed out.' }); + } else if (reason === 515) { + // 515 = stream error, often happens after pairing succeeds but before + // registration completes. Reconnect to finish the handshake. + console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); + connectSocket(phoneNumber, usePairingCode, true).then(resolve); + } else { + fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); + console.log('\n✗ Connection failed. Please try again.'); + resolve({ success: false, error: `Connection failed (reason: ${reason || 'unknown'}).` }); + } } - } - if (connection === 'open') { - fs.writeFileSync(STATUS_FILE, 'authenticated'); - // Clean up QR file now that we're connected - try { fs.unlinkSync(QR_FILE); } catch {} - console.log('\n✓ Successfully authenticated with WhatsApp!'); - console.log(' Credentials saved to store/auth/'); - console.log(' You can now start the NanoClaw service.\n'); + if (connection === 'open') { + fs.writeFileSync(STATUS_FILE, 'authenticated'); + // Clean up QR file now that we're connected + try { fs.unlinkSync(QR_FILE); } catch {} + console.log('\n✓ Successfully authenticated with WhatsApp!'); + console.log(' Credentials saved to store/auth/'); + console.log(' You can now start the NanoClaw service.\n'); - // Give it a moment to save credentials, then exit - setTimeout(() => process.exit(0), 1000); - } + // Give it a moment to save credentials, then resolve + setTimeout(() => resolve({ success: true }), 1000); + } + }); + + sock.ev.on('creds.update', saveCreds); }); - - sock.ev.on('creds.update', saveCreds); } -async function authenticate(): Promise { +export async function authenticate(options?: WhatsAppAuthOptions): Promise { fs.mkdirSync(AUTH_DIR, { recursive: true }); // Clean up any stale QR/status files from previous runs try { fs.unlinkSync(QR_FILE); } catch {} try { fs.unlinkSync(STATUS_FILE); } catch {} - let phoneNumber = phoneArg; + const usePairingCode = options?.pairingCode ?? false; + let phoneNumber = options?.phoneNumber; + if (usePairingCode && !phoneNumber) { phoneNumber = await askQuestion('Enter your phone number (with country code, no + or spaces, e.g. 14155551234): '); } console.log('Starting WhatsApp authentication...\n'); - await connectSocket(phoneNumber); + return connectSocket(phoneNumber, usePairingCode); } -authenticate().catch((err) => { - console.error('Authentication failed:', err.message); - process.exit(1); -}); +// --- Direct-run guard: keeps `npm run auth` / `npx tsx src/whatsapp-auth.ts` working --- +const isDirectRun = process.argv[1] && + new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; + +if (isDirectRun) { + const usePairingCode = process.argv.includes('--pairing-code'); + const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); + + authenticate({ pairingCode: usePairingCode, phoneNumber: phoneArg }) + .then((result) => { + if (!result.success) { + console.error('Authentication failed:', result.error); + process.exit(1); + } + process.exit(0); + }) + .catch((err) => { + console.error('Authentication failed:', err.message); + process.exit(1); + }); +}