feat: add regolith onboard CLI wizard with daemon installation
Some checks failed
Update token count / update-tokens (push) Has been cancelled

- 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
This commit is contained in:
2026-02-19 14:05:18 -05:00
parent 6c1c908fce
commit 4b2b22d044
20 changed files with 2981 additions and 164 deletions

View File

@@ -10,6 +10,11 @@ Single Node.js process that connects to WhatsApp and/or Discord, routes messages
| File | Purpose | | 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/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation |
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | | `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
| `src/channels/discord.ts` | Discord bot connection, mention handling, attachments, reply context | | `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 | | `ASSISTANT_NAME` | Andy | Trigger word for the bot |
| `DISCORD_BOT_TOKEN` | (empty) | Discord bot token; set to enable Discord | | `DISCORD_BOT_TOKEN` | (empty) | Discord bot token; set to enable Discord |
| `DISCORD_ONLY` | false | Skip WhatsApp when true | | `DISCORD_ONLY` | false | Skip WhatsApp when true |
| `AGENT_BACKEND` | container | Agent backend: "container" or "opencode" |
| `OPENCODE_MODE` | cli | OpenCode mode: "cli" or "sdk" | | `OPENCODE_MODE` | cli | OpenCode mode: "cli" or "sdk" |
| `OPENCODE_MODEL` | (unset) | Model name for OpenCode | | `OPENCODE_MODEL` | (unset) | Model name for OpenCode |
| `OPENCODE_TIMEOUT` | 120 | Timeout in seconds | | `OPENCODE_TIMEOUT` | 120 | Timeout in seconds |
| `OPENCODE_SESSION_TTL_HOURS` | 24 | Session TTL in hours | | `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 ## Skills
| Skill | When to Use | | Skill | When to Use |
@@ -52,4 +68,5 @@ npm run dev # Run with hot reload
npm run build # Compile TypeScript npm run build # Compile TypeScript
npm test # Run tests npm test # Run tests
npm run typecheck # Type check without emitting npm run typecheck # Type check without emitting
regolith onboard # Run the setup wizard
``` ```

View File

@@ -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 git clone http://10.0.0.59:3051/tanmay/Regolith.git
cd Regolith/nanoclaw cd Regolith/nanoclaw
npm install 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`: Configure your `.env`:
```bash ```bash
# WhatsApp (enabled by default) # WhatsApp (enabled by default)
@@ -38,7 +69,10 @@ ASSISTANT_NAME=Andy
DISCORD_BOT_TOKEN=your-discord-bot-token DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_ONLY=false # set to true to disable WhatsApp 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_MODE=cli # or "sdk"
OPENCODE_MODEL=claude # model name OPENCODE_MODEL=claude # model name
OPENCODE_TIMEOUT=120 # seconds OPENCODE_TIMEOUT=120 # seconds
@@ -72,6 +106,9 @@ The `findChannel(channels, jid)` function resolves which channel owns a given JI
| File | Purpose | | 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/index.ts` | Orchestrator: multi-channel setup, state, message loop, agent invocation |
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | | `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
| `src/channels/discord.ts` | Discord bot connection, mention handling, attachment tagging | | `src/channels/discord.ts` | Discord bot connection, mention handling, attachment tagging |

311
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "nanoclaw", "name": "regolith",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nanoclaw", "name": "regolith",
"version": "1.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9", "@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
@@ -17,12 +17,17 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"yargs": "^18.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"bin": {
"regolith": "dist/cli/index.js"
},
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/yargs": "^17.0.35",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"fast-check": "^4.5.3", "fast-check": "^4.5.3",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -1827,6 +1832,23 @@
"@types/node": "*" "@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": { "node_modules/@vitest/coverage-v8": {
"version": "4.0.18", "version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
@@ -2019,24 +2041,24 @@
} }
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
} }
}, },
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT", "license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": { "engines": {
"node": ">=8" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -2196,14 +2218,17 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/cliui": { "node_modules/cliui": {
"version": "6.0.0", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^7.2.0",
"strip-ansi": "^6.0.0", "strip-ansi": "^7.1.0",
"wrap-ansi": "^6.2.0" "wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/color-convert": { "node_modules/color-convert": {
@@ -2368,9 +2393,9 @@
} }
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
@@ -2431,6 +2456,15 @@
"@esbuild/win32-x64": "0.27.3" "@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": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -2592,6 +2626,18 @@
"node": "6.* || 8.* || >= 10.*" "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": { "node_modules/get-tsconfig": {
"version": "4.13.6", "version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -3390,6 +3436,128 @@
"qrcode-terminal": "bin/qrcode-terminal.js" "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": { "node_modules/quick-format-unescaped": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -3725,29 +3893,35 @@
} }
}, },
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^10.3.0",
"is-fullwidth-code-point": "^3.0.0", "get-east-asian-width": "^1.0.0",
"strip-ansi": "^6.0.1" "strip-ansi": "^7.1.0"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-ansi": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^6.0.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
} }
}, },
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
@@ -4164,17 +4338,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^6.2.1",
"string-width": "^4.1.0", "string-width": "^7.0.0",
"strip-ansi": "^6.0.0" "strip-ansi": "^7.1.0"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/wrappy": { "node_modules/wrappy": {
@@ -4205,10 +4382,13 @@
} }
}, },
"node_modules/y18n": { "node_modules/y18n": {
"version": "4.0.3", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC" "license": "ISC",
"engines": {
"node": ">=10"
}
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.2",
@@ -4226,38 +4406,29 @@
} }
}, },
"node_modules/yargs": { "node_modules/yargs": {
"version": "15.4.1", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^6.0.0", "cliui": "^9.0.1",
"decamelize": "^1.2.0", "escalade": "^3.1.1",
"find-up": "^4.1.0", "get-caller-file": "^2.0.5",
"get-caller-file": "^2.0.1", "string-width": "^7.2.0",
"require-directory": "^2.1.1", "y18n": "^5.0.5",
"require-main-filename": "^2.0.0", "yargs-parser": "^22.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": { "engines": {
"node": ">=8" "node": "^20.19.0 || ^22.12.0 || >=23"
} }
}, },
"node_modules/yargs-parser": { "node_modules/yargs-parser": {
"version": "18.1.3", "version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC", "license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": { "engines": {
"node": ">=6" "node": "^20.19.0 || ^22.12.0 || >=23"
} }
}, },
"node_modules/zod": { "node_modules/zod": {

View File

@@ -4,6 +4,9 @@
"description": "Personal AI assistant with multi-channel support (WhatsApp, Discord) and multi-runtime backends (Claude Agent SDK, OpenCode).", "description": "Personal AI assistant with multi-channel support (WhatsApp, Discord) and multi-runtime backends (Claude Agent SDK, OpenCode).",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"bin": {
"regolith": "dist/cli/index.js"
},
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
@@ -25,12 +28,14 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"yargs": "^18.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/yargs": "^17.0.35",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"fast-check": "^4.5.3", "fast-check": "^4.5.3",
"prettier": "^3.8.1", "prettier": "^3.8.1",

109
src/cli/display.test.ts Normal file
View File

@@ -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<typeof vi.spyOn>;
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');
});
});
});
});

47
src/cli/display.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { StepResult } from './types.js';
const STATUS_INDICATORS: Record<StepResult['status'], string> = {
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}`);
}
}

69
src/cli/index.ts Normal file
View File

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

View File

@@ -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> = {}): 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<string, string | Error>) {
const mock = execFileCb as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.spyOn>;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.restoreAllMocks();
// Re-mock child_process after restoreAllMocks
const mock = execFileCb as unknown as ReturnType<typeof vi.fn>;
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();
});
});

115
src/cli/steps/check-deps.ts Normal file
View File

@@ -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<string | null> {
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<string | null> {
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<StepResult> {
// --- 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,
},
};
}

View File

@@ -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> = {}): 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<typeof vi.spyOn>;
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;
}
}
});
});

View File

@@ -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<string, string> = {
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<string> {
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, string>): 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<string, string>,
): 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<string>();
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<StepResult> {
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<string, string> = { ...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 },
};
}

View File

@@ -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<typeof import('fs')>('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> = {}): 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<string, string | Error>) {
const mock = execFileCb as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.spyOn>;
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<typeof vi.fn>;
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<typeof fs.readdirSync>);
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<typeof fs.readdirSync>);
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');
});
});

View File

@@ -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<string>();
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<SubCheck> {
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<SubCheck | null> {
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<StepResult> {
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 })),
},
};
}

View File

@@ -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> = {}): 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<string, string | Error>) {
const mock = execFileCb as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.spyOn>;
const originalEnv = { ...process.env };
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const mock = execFileCb as unknown as ReturnType<typeof vi.fn>;
mock.mockReset();
(fs.readFile as ReturnType<typeof vi.fn>).mockReset();
(fs.writeFile as ReturnType<typeof vi.fn>).mockReset();
(fs.mkdir as ReturnType<typeof vi.fn>).mockReset();
(fs.mkdir as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(fs.writeFile as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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 = '<plist>{{NODE_PATH}} {{PROJECT_ROOT}} {{HOME}}</plist>';
(fs.readFile as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue('<plist>{{NODE_PATH}}</plist>');
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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});

View File

@@ -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, string>): 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<boolean> {
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<string> {
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<string> {
const templatePath = path.join(projectRoot, 'launchd', 'com.nanoclaw.plist');
try {
return await fs.readFile(templatePath, 'utf-8');
} catch {
// Fallback inline plist template
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_PATH}}</string>
<string>{{PROJECT_ROOT}}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>{{PROJECT_ROOT}}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>{{HOME}}</string>
</dict>
<key>StandardOutPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
}
}
/**
* Install the daemon on macOS using launchd.
*/
async function installLaunchd(ctx: WizardContext, values: Record<string, string>): Promise<StepResult> {
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<string, string>): Promise<StepResult> {
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<StepResult> {
// 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<string, string> = {
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);
}

View File

@@ -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> = {}): 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<typeof vi.fn>;
mockCreateInterface.mockImplementation(() => ({
question: vi.fn((_q: string, cb: (answer: string) => void) => {
cb(answers[callIndex++] ?? '');
}),
close: vi.fn(),
}));
}
describe('whatsappAuth', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
const mockAuthenticate = authenticate as unknown as ReturnType<typeof vi.fn>;
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);
});
});

View File

@@ -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<boolean> {
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<boolean> {
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<StepResult> {
// 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()
}
}

31
src/cli/types.ts Normal file
View File

@@ -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<string, unknown>;
}
export interface WizardContext {
flags: WizardFlags;
projectRoot: string;
platform: 'darwin' | 'linux';
envValues: Record<string, string>;
containerRuntime: 'apple-container' | 'docker' | null;
containerVersion: string | null;
whatsappAuthed: boolean;
daemonInstalled: boolean;
results: StepResult[];
}
export type WizardStep = (ctx: WizardContext) => Promise<StepResult>;

72
src/cli/wizard-runner.ts Normal file
View File

@@ -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<StepResult>;
}
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<number> {
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;
}

View File

@@ -1,10 +1,11 @@
/** /**
* WhatsApp Authentication Script * WhatsApp Authentication Module
* *
* Run this during setup to authenticate with WhatsApp. * Exports an `authenticate()` function for programmatic use (e.g. from the onboard wizard).
* Displays QR code, waits for scan, saves credentials, then exits. * 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 <number>]
* Usage (import): import { authenticate } from './whatsapp-auth.js';
*/ */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -27,9 +28,15 @@ const logger = pino({
level: 'warn', // Quiet logging - only show errors level: 'warn', // Quiet logging - only show errors
}); });
// Check for --pairing-code flag and phone number export interface WhatsAppAuthOptions {
const usePairingCode = process.argv.includes('--pairing-code'); pairingCode?: boolean;
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); phoneNumber?: string;
}
export interface WhatsAppAuthResult {
success: boolean;
error?: string;
}
function askQuestion(prompt: string): Promise<string> { function askQuestion(prompt: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -41,7 +48,8 @@ function askQuestion(prompt: string): Promise<string> {
}); });
} }
async function connectSocket(phoneNumber?: string, isReconnect = false): Promise<void> { function connectSocket(phoneNumber?: string, usePairingCode?: boolean, isReconnect = false): Promise<WhatsAppAuthResult> {
return new Promise(async (resolve) => {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
if (state.creds.registered && !isReconnect) { if (state.creds.registered && !isReconnect) {
@@ -50,7 +58,8 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
console.log( console.log(
' To re-authenticate, delete the store/auth folder and run again.', ' To re-authenticate, delete the store/auth folder and run again.',
); );
process.exit(0); resolve({ success: true });
return;
} }
const sock = makeWASocket({ const sock = makeWASocket({
@@ -77,7 +86,7 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`);
} catch (err: any) { } catch (err: any) {
console.error('Failed to request pairing code:', err.message); console.error('Failed to request pairing code:', err.message);
process.exit(1); resolve({ success: false, error: `Failed to request pairing code: ${err.message}` });
} }
}, 3000); }, 3000);
} }
@@ -101,20 +110,20 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
if (reason === DisconnectReason.loggedOut) { if (reason === DisconnectReason.loggedOut) {
fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); fs.writeFileSync(STATUS_FILE, 'failed:logged_out');
console.log('\n✗ Logged out. Delete store/auth and try again.'); console.log('\n✗ Logged out. Delete store/auth and try again.');
process.exit(1); resolve({ success: false, error: 'Logged out. Delete store/auth and try again.' });
} else if (reason === DisconnectReason.timedOut) { } else if (reason === DisconnectReason.timedOut) {
fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout');
console.log('\n✗ QR code timed out. Please try again.'); console.log('\n✗ QR code timed out. Please try again.');
process.exit(1); resolve({ success: false, error: 'QR code timed out.' });
} else if (reason === 515) { } else if (reason === 515) {
// 515 = stream error, often happens after pairing succeeds but before // 515 = stream error, often happens after pairing succeeds but before
// registration completes. Reconnect to finish the handshake. // registration completes. Reconnect to finish the handshake.
console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); console.log('\n⟳ Stream error (515) after pairing — reconnecting...');
connectSocket(phoneNumber, true); connectSocket(phoneNumber, usePairingCode, true).then(resolve);
} else { } else {
fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`);
console.log('\n✗ Connection failed. Please try again.'); console.log('\n✗ Connection failed. Please try again.');
process.exit(1); resolve({ success: false, error: `Connection failed (reason: ${reason || 'unknown'}).` });
} }
} }
@@ -126,32 +135,52 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
console.log(' Credentials saved to store/auth/'); console.log(' Credentials saved to store/auth/');
console.log(' You can now start the NanoClaw service.\n'); console.log(' You can now start the NanoClaw service.\n');
// Give it a moment to save credentials, then exit // Give it a moment to save credentials, then resolve
setTimeout(() => process.exit(0), 1000); setTimeout(() => resolve({ success: true }), 1000);
} }
}); });
sock.ev.on('creds.update', saveCreds); sock.ev.on('creds.update', saveCreds);
});
} }
async function authenticate(): Promise<void> { export async function authenticate(options?: WhatsAppAuthOptions): Promise<WhatsAppAuthResult> {
fs.mkdirSync(AUTH_DIR, { recursive: true }); fs.mkdirSync(AUTH_DIR, { recursive: true });
// Clean up any stale QR/status files from previous runs // Clean up any stale QR/status files from previous runs
try { fs.unlinkSync(QR_FILE); } catch {} try { fs.unlinkSync(QR_FILE); } catch {}
try { fs.unlinkSync(STATUS_FILE); } catch {} try { fs.unlinkSync(STATUS_FILE); } catch {}
let phoneNumber = phoneArg; const usePairingCode = options?.pairingCode ?? false;
let phoneNumber = options?.phoneNumber;
if (usePairingCode && !phoneNumber) { if (usePairingCode && !phoneNumber) {
phoneNumber = await askQuestion('Enter your phone number (with country code, no + or spaces, e.g. 14155551234): '); phoneNumber = await askQuestion('Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ');
} }
console.log('Starting WhatsApp authentication...\n'); console.log('Starting WhatsApp authentication...\n');
await connectSocket(phoneNumber); return connectSocket(phoneNumber, usePairingCode);
} }
authenticate().catch((err) => { // --- 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); console.error('Authentication failed:', err.message);
process.exit(1); process.exit(1);
}); });
}