feat: add regolith onboard CLI wizard with daemon installation
Some checks failed
Update token count / update-tokens (push) Has been cancelled
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:
17
CLAUDE.md
17
CLAUDE.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -27,8 +27,39 @@ The core philosophy remains the same: small enough to understand, secure by cont
|
|||||||
git clone http://10.0.0.59:3051/tanmay/Regolith.git
|
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
311
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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
109
src/cli/display.test.ts
Normal 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
47
src/cli/display.ts
Normal 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
69
src/cli/index.ts
Normal 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();
|
||||||
181
src/cli/steps/check-deps.test.ts
Normal file
181
src/cli/steps/check-deps.test.ts
Normal 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
115
src/cli/steps/check-deps.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
279
src/cli/steps/configure-env.test.ts
Normal file
279
src/cli/steps/configure-env.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/cli/steps/configure-env.ts
Normal file
193
src/cli/steps/configure-env.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
317
src/cli/steps/health-check.test.ts
Normal file
317
src/cli/steps/health-check.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
221
src/cli/steps/health-check.ts
Normal file
221
src/cli/steps/health-check.ts
Normal 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 })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
343
src/cli/steps/install-daemon.test.ts
Normal file
343
src/cli/steps/install-daemon.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
src/cli/steps/install-daemon.ts
Normal file
273
src/cli/steps/install-daemon.ts
Normal 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);
|
||||||
|
}
|
||||||
186
src/cli/steps/whatsapp-auth.test.ts
Normal file
186
src/cli/steps/whatsapp-auth.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
src/cli/steps/whatsapp-auth.ts
Normal file
122
src/cli/steps/whatsapp-auth.ts
Normal 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
31
src/cli/types.ts
Normal 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
72
src/cli/wizard-runner.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user