Migrate setup from bash scripts to cross-platform Node.js modules (#382)

* refactor: migrate setup from bash scripts to cross-platform Node.js modules

Replace 9 bash scripts + qr-auth.html with a two-phase setup system:
a bash bootstrap (setup.sh) for Node.js/npm verification, and TypeScript
modules (src/setup/) for everything else. Resolves cross-platform issues:
sed -i replaced with fs operations, sqlite3 CLI replaced with better-sqlite3,
browser opening made cross-platform, service management supports launchd/
systemd/WSL nohup fallback, SQL injection prevented with parameterized queries.

Add Linux systemctl equivalents alongside macOS launchctl commands in 8 skill
files and CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: setup migration issues — pairing code, systemd fallback, nohup escaping

- Emit WhatsApp pairing code immediately when received, before polling
  for auth completion. Previously the code was only shown in the final
  status block after auth succeeded — a catch-22 since the user needs
  the code to authenticate. (whatsapp-auth.ts)

- Add systemd user session pre-check before attempting to write the
  user-level service unit. Falls back to nohup wrapper when user-level
  systemd is unavailable (e.g. su session without login/D-Bus). (service.ts)

- Rewrite nohup wrapper template using array join instead of template
  literal to fix shell variable escaping (\\$ → $). (service.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: detect stale docker group and kill
  orphaned processes on Linux systemd

* fix: remove redundant shell option from execSync to fix TS2769

execSync already runs in a shell by default; the explicit `shell: true`
caused a type error with @types/node which expects string, not boolean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: hide QR browser auth option on headless Linux

Emit IS_HEADLESS from environment step and condition SKILL.md to
only show pairing code + QR terminal when no display server is
available (headless Linux without WSL). WSL is excluded from the
headless gate because browser opening works via Windows interop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel M
2026-02-22 18:25:11 +02:00
committed by GitHub
parent 1980d97d90
commit 8fc1c23925
38 changed files with 2504 additions and 1496 deletions

View File

@@ -246,13 +246,15 @@ cd .. && npm run build
Wait for TypeScript compilation, then restart the service:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Check that it started:
```bash
sleep 2 && launchctl list | grep nanoclaw
sleep 2 && launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### Step 5: Test Gmail Integration
@@ -651,7 +653,8 @@ cd .. && npm run build
Restart the service:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Verify it started and check for email channel startup message:
@@ -726,5 +729,6 @@ To remove Gmail entirely:
```bash
cd container && ./build.sh && cd ..
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```

View File

@@ -235,13 +235,15 @@ Rebuild the main app and restart:
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep nanoclaw
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### 8. Test Integration
@@ -287,4 +289,4 @@ To remove Parallel AI integration:
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && npm run build`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)

View File

@@ -321,11 +321,14 @@ Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.n
```bash
npm run build
./container/build.sh # Required — MCP tool changed
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
```
Must use `unload/load` (not just `kickstart`) because the plist env vars changed.
Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed.
### Step 8: Test
@@ -377,5 +380,5 @@ To remove Agent Swarm support while keeping basic Telegram:
4. Remove `initBotPool` call from `main()`
5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts`
6. Remove Agent Teams section from group CLAUDE.md files
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist
8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit
8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)

View File

@@ -118,7 +118,8 @@ Tell the user:
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Registration
@@ -183,10 +184,11 @@ tail -f logs/nanoclaw.log
### Bot not responding
1. Check `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Check chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`
3. For non-main chats: message must include trigger pattern
4. Service is running: `launchctl list | grep nanoclaw`
Check:
1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
3. For non-main chats: message includes trigger pattern
4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
### Bot only responds to @mentions in groups
@@ -202,6 +204,36 @@ If `/chatid` doesn't work:
## After Setup
Ask the user:
If running `npm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```
> Would you like to add Agent Swarm support? Each subagent appears as a different bot in the Telegram group. If interested, run `/add-telegram-swarm`.
## Agent Swarms (Teams)
After completing the Telegram setup, ask the user:
> Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:
1. Delete `src/channels/telegram.ts`
2. Remove `TelegramChannel` import and creation from `src/index.ts`
3. Remove `channels` array and revert to using `whatsapp` directly in `processGroupMessages`, scheduler deps, and IPC deps
4. Revert `getAvailableGroups()` filter to only include `@g.us` chats
5. Remove Telegram config (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ONLY`) from `src/config.ts`
6. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
7. Uninstall: `npm uninstall grammy`
8. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)

View File

@@ -93,7 +93,8 @@ The container reads environment from `data/env/env`, not `.env` directly.
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify

View File

@@ -92,8 +92,11 @@ Always tell the user:
```bash
# Rebuild and restart
npm run build
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
```
## Example Interaction

View File

@@ -43,9 +43,11 @@ Set `LOG_LEVEL=debug` for verbose output:
# For development
LOG_LEVEL=debug npm run dev
# For launchd service, add to plist EnvironmentVariables:
# For launchd service (macOS), add to plist EnvironmentVariables:
<key>LOG_LEVEL</key>
<string>debug</string>
# For systemd service (Linux), add to unit [Service] section:
# Environment=LOG_LEVEL=debug
```
Debug level shows:

View File

@@ -5,54 +5,46 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen
# NanoClaw Setup
Run setup scripts automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). Scripts live in `.claude/skills/setup/scripts/` and emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
Run setup steps automatically. Only pause when user action is required (WhatsApp authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx src/setup/index.ts --step <name>` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. scanning a QR code, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
**UX Note:** Use `AskUserQuestion` for all user-facing questions.
## 1. Check Environment
## 1. Bootstrap (Node.js + Dependencies)
Run `./.claude/skills/setup/scripts/01-check-environment.sh` and parse the status block.
Run `bash setup.sh` and parse the status block.
- If NODE_OK=false → Node.js is missing or too old. Ask the user if they'd like you to install it:
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
- After installing Node, re-run `bash setup.sh`
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules` and `package-lock.json`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
## 2. Check Environment
Run `npx tsx src/setup/index.ts --step environment` and parse the status block.
- If HAS_AUTH=true → note that WhatsApp auth exists, offer to skip step 5
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record PLATFORM, APPLE_CONTAINER, and DOCKER values for step 3
**If NODE_OK=false:**
Node.js is missing or too old. Ask the user if they'd like you to install it. Offer options based on platform:
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
If brew/nvm aren't installed, install them first (`/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` for brew, `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` for nvm). After installing Node, re-run the environment check to confirm NODE_OK=true.
## 2. Install Dependencies
Run `./.claude/skills/setup/scripts/02-install-deps.sh` and parse the status block.
**If failed:** Read the tail of `logs/setup.log` to diagnose. Common fixes to try automatically:
1. Delete `node_modules` and `package-lock.json`, then re-run the script
2. If permission errors: suggest running with corrected permissions
3. If specific package fails to build (native modules like better-sqlite3): install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry
Only ask the user for help if multiple retries fail with the same error.
- Record APPLE_CONTAINER and DOCKER values for step 3
## 3. Container Runtime
### 3a. Choose runtime
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`.
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
**If APPLE_CONTAINER=installed** (macOS only): Ask the user which runtime they'd like to use — Docker (default, cross-platform) or Apple Container (native macOS). If they choose Apple Container, run `/convert-to-apple-container` now before continuing, then skip to 3b.
**If APPLE_CONTAINER=not_found**: Use Docker (the default). Proceed to install/start Docker below.
- PLATFORM=linux → Docker (only option)
- PLATFORM=macos + APPLE_CONTAINER=installed → Ask user: Docker (default, cross-platform) or Apple Container (native macOS)? If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
- PLATFORM=macos + APPLE_CONTAINER=not_found Docker (default)
### 3a-docker. Install Docker
- DOCKER=running → continue to 3b
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. If still not running, tell the user Docker is starting up and poll a few more times.
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`.
- DOCKER=not_found → **ask the user for confirmation before installing.** Tell them Docker is required for running agents and ask if they'd like you to install it. If confirmed:
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
@@ -73,146 +65,122 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
### 3c. Build and test
Run `./.claude/skills/setup/scripts/03-setup-container.sh --runtime <chosen>` and parse the status block.
Run `npx tsx src/setup/index.ts --step container -- --runtime <chosen>` and parse the status block.
**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error.
- If it's a cache issue (stale layers): run `docker builder prune -f`, then retry.
- If Dockerfile syntax or missing files: diagnose from the log and fix.
- Retry the build script after fixing.
- Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry.
- Dockerfile syntax or missing files: diagnose from the log and fix, then retry.
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Claude Authentication (No Script)
If HAS_ENV=true from step 1, read `.env` and check if it already has `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If so, confirm with user: "You already have Claude credentials configured. Want to keep them or reconfigure?" If keeping, skip to step 5.
If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure?
AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
**Subscription:** Tell the user:
1. Open another terminal and run: `claude setup-token`
2. Copy the token it outputs
3. Add it to the `.env` file in the project root: `CLAUDE_CODE_OAUTH_TOKEN=<token>`
4. Let me know when done
**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat.
Do NOT ask the user to paste the token into the chat. Do NOT use AskUserQuestion to collect the token. Just tell them what to do, then wait for confirmation that they've added it to `.env`. Once confirmed, verify the `.env` file has the key.
**API key:** Tell the user to add `ANTHROPIC_API_KEY=<key>` to the `.env` file in the project root, then let you know when done. Once confirmed, verify the `.env` file has the key.
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
## 5. WhatsApp Authentication
If HAS_AUTH=true from step 1, confirm with user: "WhatsApp credentials already exist. Want to keep them or re-authenticate?" If keeping, skip to step 6.
If HAS_AUTH=true, confirm: keep or re-authenticate?
AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal?
**Choose auth method based on environment (from step 2):**
- **QR browser:** Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method qr-browser` (Bash timeout: 150000ms)
- **Pairing code:** Ask for phone number first (country code, no + or spaces, e.g. 14155551234). Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method pairing-code --phone NUMBER` (Bash timeout: 150000ms). Display the PAIRING_CODE from the status block with instructions.
- **QR terminal:** Run `./.claude/skills/setup/scripts/04-auth-whatsapp.sh --method qr-terminal`. Tell user to run `cd PROJECT_PATH && npm run auth` in another terminal. Wait for confirmation.
If IS_HEADLESS=true AND IS_WSL=false → AskUserQuestion: Pairing code (recommended) vs QR code in terminal?
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: QR code in browser (recommended) vs pairing code vs QR code in terminal?
If AUTH_STATUS=already_authenticated → skip ahead.
- **QR browser:** `npx tsx src/setup/index.ts --step whatsapp-auth -- --method qr-browser` (Bash timeout: 150000ms)
- **Pairing code:** Ask for phone number first. `npx tsx src/setup/index.ts --step whatsapp-auth -- --method pairing-code --phone NUMBER` (Bash timeout: 150000ms). Display PAIRING_CODE.
- **QR terminal:** `npx tsx src/setup/index.ts --step whatsapp-auth -- --method qr-terminal`. Tell user to run `npm run auth` in another terminal.
**If failed:**
- qr_timeout → QR expired. Automatically re-run the auth script to generate a fresh QR. Tell user a new QR is ready.
- logged_out → Delete `store/auth/` and re-run auth automatically.
- 515 → Stream error during pairing. The auth script handles reconnection, but if it persists, re-run the auth script.
- timeout → Auth took too long. Ask user if they scanned/entered the code, offer to retry.
**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
## 6. Configure Trigger and Channel Type
First, determine the phone number situation. Get the bot's WhatsApp number from `store/auth/creds.json`:
`node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
Get bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`
AskUserQuestion: Does the bot share your personal WhatsApp number, or does it have its own dedicated phone number?
AskUserQuestion: Shared number or dedicated? → AskUserQuestion: Trigger word? → AskUserQuestion: Main channel type?
AskUserQuestion: What trigger word? (default: Andy). In group chats, messages starting with @TriggerWord go to Claude. In the main channel, no prefix needed.
AskUserQuestion: Main channel type? (options depend on phone number setup)
**If bot shares user's number (same phone):**
1. Self-chat (chat with yourself) — Recommended. You message yourself and the bot responds.
2. Solo group (just you) — A group where you're the only member. Good if you want message history separate from self-chat.
**If bot has its own dedicated phone number:**
1. DM with the bot — Recommended. You message the bot's number directly.
2. Solo group with the bot — A group with just you and the bot, no one else.
Do NOT show options that don't apply to the user's setup. For example, don't offer "DM with the bot" if the bot shares the user's number (you can't DM yourself on WhatsApp).
**Shared number:** Self-chat (recommended) or Solo group
**Dedicated number:** DM with bot (recommended) or Solo group with bot
## 7. Sync and Select Group (If Group Channel)
**For personal chat:** The JID is the bot's own phone number from step 6. Construct as `NUMBER@s.whatsapp.net`.
**Personal chat:** JID = `NUMBER@s.whatsapp.net`
**DM with bot:** Ask for bot's number, JID = `NUMBER@s.whatsapp.net`
**For DM with bot's dedicated number:** Ask for the bot's phone number, construct JID as `NUMBER@s.whatsapp.net`.
**For group (solo or with bot):**
1. Run `./.claude/skills/setup/scripts/05-sync-groups.sh` (Bash timeout: 60000ms)
2. **If BUILD=failed:** Read `logs/setup.log`, fix the TypeScript error, re-run.
3. **If GROUPS_IN_DB=0:** Check `logs/setup.log` for the sync output. Common causes: WhatsApp auth expired (re-run step 5), connection timeout (re-run sync script with longer timeout).
4. Run `./.claude/skills/setup/scripts/05b-list-groups.sh` to get groups (pipe-separated JID|name lines). Do NOT display the output to the user.
5. Pick the most likely candidates (e.g. groups with the trigger word or "NanoClaw" in the name, small/solo groups) and present them as AskUserQuestion options — show names only, not JIDs. Include an "Other" option if their group isn't listed. If they pick Other, search by name in the DB or re-run with a higher limit.
**Group:**
1. `npx tsx src/setup/index.ts --step groups` (Bash timeout: 60000ms)
2. BUILD=failed → fix TypeScript, re-run. GROUPS_IN_DB=0 → check logs.
3. `npx tsx src/setup/index.ts --step groups -- --list` for pipe-separated JID|name lines.
4. Present candidates as AskUserQuestion (names only, not JIDs).
## 8. Register Channel
Run `./.claude/skills/setup/scripts/06-register-channel.sh` with args:
- `--jid "JID"` — from step 7
- `--name "main"` — always "main" for the first channel
- `--trigger "@TriggerWord"` — from step 6
- `--folder "main"` — always "main" for the first channel
- `--no-trigger-required` — if personal chat, DM, or solo group
- `--assistant-name "Name"` — if trigger word differs from "Andy"
Run `npx tsx src/setup/index.ts --step register -- --jid "JID" --name "main" --trigger "@TriggerWord" --folder "main"` plus `--no-trigger-required` if personal/DM/solo, `--assistant-name "Name"` if not Andy.
## 9. Mount Allowlist
AskUserQuestion: Want the agent to access directories outside the NanoClaw project? (Git repos, project folders, documents, etc.)
AskUserQuestion: Agent access to external directories?
**If no:** Run `./.claude/skills/setup/scripts/07-configure-mounts.sh --empty`
**If yes:** Collect directory paths and permissions (read-write vs read-only). Ask about non-main group read-only restriction (recommended: yes). Build the JSON and pipe it to the script:
`echo '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}' | ./.claude/skills/setup/scripts/07-configure-mounts.sh`
Tell user how to grant a group access: add `containerConfig.additionalMounts` to their entry in `data/registered_groups.json`.
**No:** `npx tsx src/setup/index.ts --step mounts -- --empty`
**Yes:** Collect paths/permissions. `npx tsx src/setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
## 10. Start Service
If the service is already running (check `launchctl list | grep nanoclaw` on macOS), unload it first: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` — then proceed with a clean install.
If service already running: unload first.
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root)
Run `./.claude/skills/setup/scripts/08-setup-service.sh` and parse the status block.
Run `npx tsx src/setup/index.ts --step service` and parse the status block.
**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper.
**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands:
1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock`
2. Persistent fix (re-applies after every Docker restart):
```bash
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF'
[Service]
ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock
EOF
sudo systemctl daemon-reload
```
Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step.
**If SERVICE_LOADED=false:**
- Read `logs/setup.log` for the error.
- Common fix: plist already loaded with different path. Unload the old one first, then re-run.
- On macOS: check `launchctl list | grep nanoclaw` to see if it's loaded with an error status. If the PID column is `-` and the status column is non-zero, the service is crashing. Read `logs/nanoclaw.error.log` for the crash reason and fix it (common: wrong Node path, missing .env, missing auth).
- On Linux: check `systemctl --user status nanoclaw` for the error and fix accordingly.
- Re-run the setup-service script after fixing.
- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`.
- Linux: check `systemctl --user status nanoclaw`.
- Re-run the service step after fixing.
## 11. Verify
Run `./.claude/skills/setup/scripts/09-verify.sh` and parse the status block.
Run `npx tsx src/setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each failing component:**
- SERVICE=stopped → run `npm run build` first, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux). Re-check.
- SERVICE=not_found → re-run step 10.
- CREDENTIALS=missing → re-run step 4.
- WHATSAPP_AUTH=not_found → re-run step 5.
- REGISTERED_GROUPS=0 → re-run steps 7-8.
- MOUNT_ALLOWLIST=missing → run `./.claude/skills/setup/scripts/07-configure-mounts.sh --empty` to create a default.
**If STATUS=failed, fix each:**
- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 10
- CREDENTIALS=missing → re-run step 4
- WHATSAPP_AUTH=not_found → re-run step 5
- REGISTERED_GROUPS=0 → re-run steps 7-8
- MOUNT_ALLOWLIST=missing → `npx tsx src/setup/index.ts --step mounts -- --empty`
After fixing, re-run `09-verify.sh` to confirm everything passes.
Tell user to test: send a message in their registered chat (with or without trigger depending on channel type).
Show the log tail command: `tail -f logs/nanoclaw.log`
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common causes: wrong Node path in plist (re-run step 10), missing `.env` (re-run step 4), missing WhatsApp auth (re-run step 5).
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 10), missing `.env` (step 4), missing auth (step 5).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — start it with the appropriate command for your runtime. Check container logs in `groups/main/logs/container-*.log`.
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
**No response to messages:** Verify the trigger pattern matches. Main channel and personal/solo chats don't need a prefix. Check the registered JID in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"`. Check `logs/nanoclaw.log`.
**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx src/setup/index.ts --step verify`. Check `logs/nanoclaw.log`.
**Messages sent but not received (DMs):** WhatsApp may use LID (Linked Identity) JIDs. Check logs for LID translation. Verify the registered JID has no device suffix (should be `number@s.whatsapp.net`, not `number:0@s.whatsapp.net`).
**WhatsApp disconnected:** `npm run auth` then rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux).
**WhatsApp disconnected:** Run `npm run auth` to re-authenticate, then `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`.
**Unload service:** `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`

View File

@@ -1,108 +0,0 @@
#!/bin/bash
set -euo pipefail
# 01-check-environment.sh — Detect OS, Node, container runtimes, existing config
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [check-environment] $*" >> "$LOG_FILE"; }
log "Starting environment check"
# Detect platform
UNAME=$(uname -s)
case "$UNAME" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
log "Platform: $PLATFORM ($UNAME)"
# Check Node
NODE_OK="false"
NODE_VERSION="not_found"
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version 2>/dev/null | sed 's/^v//')
MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
if [ "$MAJOR" -ge 20 ] 2>/dev/null; then
NODE_OK="true"
fi
log "Node $NODE_VERSION found (major=$MAJOR, ok=$NODE_OK)"
else
log "Node not found"
fi
# Check Apple Container
APPLE_CONTAINER="not_found"
if command -v container >/dev/null 2>&1; then
APPLE_CONTAINER="installed"
log "Apple Container: installed ($(which container))"
else
log "Apple Container: not found"
fi
# Check Docker
DOCKER="not_found"
if command -v docker >/dev/null 2>&1; then
if docker info >/dev/null 2>&1; then
DOCKER="running"
log "Docker: running"
else
DOCKER="installed_not_running"
log "Docker: installed but not running"
fi
else
log "Docker: not found"
fi
# Check existing config
HAS_ENV="false"
if [ -f "$PROJECT_ROOT/.env" ]; then
HAS_ENV="true"
log ".env file found"
fi
HAS_AUTH="false"
if [ -d "$PROJECT_ROOT/store/auth" ] && [ "$(ls -A "$PROJECT_ROOT/store/auth" 2>/dev/null)" ]; then
HAS_AUTH="true"
log "WhatsApp auth credentials found"
fi
HAS_REGISTERED_GROUPS="false"
if [ -f "$PROJECT_ROOT/data/registered_groups.json" ]; then
HAS_REGISTERED_GROUPS="true"
log "Registered groups config found (JSON)"
elif [ -f "$PROJECT_ROOT/store/messages.db" ]; then
RG_COUNT=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo "0")
if [ "$RG_COUNT" -gt 0 ] 2>/dev/null; then
HAS_REGISTERED_GROUPS="true"
log "Registered groups found in database ($RG_COUNT)"
fi
fi
log "Environment check complete"
# Output structured status block
cat <<EOF
=== NANOCLAW SETUP: CHECK_ENVIRONMENT ===
PLATFORM: $PLATFORM
NODE_VERSION: $NODE_VERSION
NODE_OK: $NODE_OK
APPLE_CONTAINER: $APPLE_CONTAINER
DOCKER: $DOCKER
HAS_ENV: $HAS_ENV
HAS_AUTH: $HAS_AUTH
HAS_REGISTERED_GROUPS: $HAS_REGISTERED_GROUPS
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
# Exit 2 if Node is missing or too old
if [ "$NODE_OK" = "false" ]; then
exit 2
fi

View File

@@ -1,62 +0,0 @@
#!/bin/bash
set -euo pipefail
# 02-install-deps.sh — Run npm install and verify key packages
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [install-deps] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
log "Running npm install"
if npm install >> "$LOG_FILE" 2>&1; then
log "npm install succeeded"
else
log "npm install failed"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: failed
STATUS: failed
ERROR: npm_install_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Verify key packages
MISSING=""
for pkg in @whiskeysockets/baileys better-sqlite3 pino qrcode; do
if [ ! -d "$PROJECT_ROOT/node_modules/$pkg" ]; then
MISSING="$MISSING $pkg"
fi
done
if [ -n "$MISSING" ]; then
log "Missing packages after install:$MISSING"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: failed
STATUS: failed
ERROR: missing_packages:$MISSING
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
log "All key packages verified"
cat <<EOF
=== NANOCLAW SETUP: INSTALL_DEPS ===
PACKAGES: installed
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,150 +0,0 @@
#!/bin/bash
set -euo pipefail
# 03-setup-container.sh — Build container image and verify with test run
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [setup-container] $*" >> "$LOG_FILE"; }
# Parse args
RUNTIME=""
while [[ $# -gt 0 ]]; do
case $1 in
--runtime) RUNTIME="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$RUNTIME" ]; then
log "ERROR: --runtime flag is required (apple-container|docker)"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: unknown
IMAGE: nanoclaw-agent:latest
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: missing_runtime_flag
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
IMAGE="nanoclaw-agent:latest"
# Determine build/run commands based on runtime
case "$RUNTIME" in
apple-container)
if ! command -v container >/dev/null 2>&1; then
log "Apple Container runtime not found"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: apple-container
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: runtime_not_available
LOG: logs/setup.log
=== END ===
EOF
exit 2
fi
BUILD_CMD="container build"
RUN_CMD="container"
;;
docker)
if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
log "Docker runtime not available or not running"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: docker
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: runtime_not_available
LOG: logs/setup.log
=== END ===
EOF
exit 2
fi
BUILD_CMD="docker build"
RUN_CMD="docker"
;;
*)
log "Unknown runtime: $RUNTIME"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: unknown_runtime
LOG: logs/setup.log
=== END ===
EOF
exit 4
;;
esac
log "Building container with $RUNTIME"
# Build
BUILD_OK="false"
if (cd "$PROJECT_ROOT/container" && $BUILD_CMD -t "$IMAGE" .) >> "$LOG_FILE" 2>&1; then
BUILD_OK="true"
log "Container build succeeded"
else
log "Container build failed"
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: false
TEST_OK: false
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Test
TEST_OK="false"
log "Testing container with echo command"
TEST_OUTPUT=$(echo '{}' | $RUN_CMD run -i --rm --entrypoint /bin/echo "$IMAGE" "Container OK" 2>>"$LOG_FILE") || true
if echo "$TEST_OUTPUT" | grep -q "Container OK"; then
TEST_OK="true"
log "Container test passed"
else
log "Container test failed: $TEST_OUTPUT"
fi
STATUS="success"
if [ "$BUILD_OK" = "false" ] || [ "$TEST_OK" = "false" ]; then
STATUS="failed"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_CONTAINER ===
RUNTIME: $RUNTIME
IMAGE: $IMAGE
BUILD_OK: $BUILD_OK
TEST_OK: $TEST_OK
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,347 +0,0 @@
#!/bin/bash
set -euo pipefail
# 04-auth-whatsapp.sh — Full WhatsApp auth flow with polling
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [auth-whatsapp] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
METHOD=""
PHONE=""
while [[ $# -gt 0 ]]; do
case $1 in
--method) METHOD="$2"; shift 2 ;;
--phone) PHONE="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$METHOD" ]; then
log "ERROR: --method flag is required"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: unknown
AUTH_STATUS: failed
STATUS: failed
ERROR: missing_method_flag
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
# Background process PID for cleanup
AUTH_PID=""
cleanup() {
if [ -n "$AUTH_PID" ] && kill -0 "$AUTH_PID" 2>/dev/null; then
log "Cleaning up auth process (PID $AUTH_PID)"
kill "$AUTH_PID" 2>/dev/null || true
wait "$AUTH_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Helper: poll a file for a pattern
# Usage: poll_file FILE PATTERN TIMEOUT_SECS INTERVAL_SECS
poll_file() {
local file="$1" pattern="$2" timeout="$3" interval="$4"
local elapsed=0
while [ "$elapsed" -lt "$timeout" ]; do
if [ -f "$file" ]; then
local content
content=$(cat "$file" 2>/dev/null || echo "")
if echo "$content" | grep -qE "$pattern"; then
echo "$content"
return 0
fi
fi
sleep "$interval"
elapsed=$((elapsed + interval))
done
return 1
}
# Helper: get phone number from auth creds if available
get_phone_number() {
if [ -f "$PROJECT_ROOT/store/auth/creds.json" ]; then
node -e "
const c = require('./store/auth/creds.json');
if (c.me && c.me.id) {
const phone = c.me.id.split(':')[0].split('@')[0];
process.stdout.write(phone);
}
" 2>/dev/null || true
fi
}
clean_stale_state() {
log "Cleaning stale auth state"
rm -rf "$PROJECT_ROOT/store/auth" "$PROJECT_ROOT/store/qr-data.txt" "$PROJECT_ROOT/store/auth-status.txt"
}
emit_status() {
local auth_status="$1" status="$2" error="${3:-}" pairing_code="${4:-}"
local phone_number
phone_number=$(get_phone_number)
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: $METHOD
AUTH_STATUS: $auth_status
EOF
[ -n "$pairing_code" ] && echo "PAIRING_CODE: $pairing_code"
[ -n "$phone_number" ] && echo "PHONE_NUMBER: $phone_number"
echo "STATUS: $status"
[ -n "$error" ] && echo "ERROR: $error"
cat <<EOF
LOG: logs/setup.log
=== END ===
EOF
}
case "$METHOD" in
qr-browser)
log "Starting QR browser auth flow"
clean_stale_state
# Start auth in background
npm run auth >> "$LOG_FILE" 2>&1 &
AUTH_PID=$!
log "Auth process started (PID $AUTH_PID)"
# Poll for QR data or already_authenticated
log "Polling for QR data (15s timeout)"
QR_READY="false"
for i in $(seq 1 15); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
if [ "$STATUS_CONTENT" = "already_authenticated" ]; then
log "Already authenticated"
emit_status "already_authenticated" "success"
exit 0
fi
fi
if [ -f "$PROJECT_ROOT/store/qr-data.txt" ]; then
QR_READY="true"
break
fi
# Check if auth process died early
if ! kill -0 "$AUTH_PID" 2>/dev/null; then
log "Auth process exited prematurely"
emit_status "failed" "failed" "auth_process_crashed"
exit 1
fi
sleep 1
done
if [ "$QR_READY" = "false" ]; then
log "Timeout waiting for QR data"
emit_status "failed" "failed" "qr_timeout"
exit 3
fi
# Generate QR SVG and inject into HTML template
log "Generating QR SVG"
node -e "
const QR = require('qrcode');
const fs = require('fs');
const qrData = fs.readFileSync('store/qr-data.txt', 'utf8');
QR.toString(qrData, { type: 'svg' }, (err, svg) => {
if (err) process.exit(1);
const template = fs.readFileSync('.claude/skills/setup/scripts/qr-auth.html', 'utf8');
fs.writeFileSync('store/qr-auth.html', template.replace('{{QR_SVG}}', svg));
});
" >> "$LOG_FILE" 2>&1
# Open in browser (macOS)
if command -v open >/dev/null 2>&1; then
open "$PROJECT_ROOT/store/qr-auth.html"
log "Opened QR auth page in browser"
else
log "WARNING: 'open' command not found, cannot open browser"
fi
# Poll for completion (120s, 2s intervals)
log "Polling for auth completion (120s timeout)"
for i in $(seq 1 60); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
authenticated|already_authenticated)
log "Authentication successful: $STATUS_CONTENT"
# Replace QR page with success page so browser auto-refresh shows it
cat > "$PROJECT_ROOT/store/qr-auth.html" <<'SUCCESSEOF'
<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>
SUCCESSEOF
emit_status "$STATUS_CONTENT" "success"
exit 0
;;
failed:logged_out)
log "Auth failed: logged out"
emit_status "failed" "failed" "logged_out"
exit 1
;;
failed:qr_timeout)
log "Auth failed: QR timeout"
emit_status "failed" "failed" "qr_timeout"
exit 1
;;
failed:515)
log "Auth failed: 515 stream error"
emit_status "failed" "failed" "515"
exit 1
;;
failed:*)
log "Auth failed: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}"
exit 1
;;
esac
fi
sleep 2
done
log "Timeout waiting for auth completion"
emit_status "failed" "failed" "timeout"
exit 3
;;
pairing-code)
if [ -z "$PHONE" ]; then
log "ERROR: --phone is required for pairing-code method"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: pairing-code
AUTH_STATUS: failed
STATUS: failed
ERROR: missing_phone_number
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
log "Starting pairing code auth flow (phone: $PHONE)"
clean_stale_state
# Start auth with pairing code in background
npx tsx src/whatsapp-auth.ts --pairing-code --phone "$PHONE" >> "$LOG_FILE" 2>&1 &
AUTH_PID=$!
log "Auth process started (PID $AUTH_PID)"
# Poll for pairing code or already_authenticated
log "Polling for pairing code (15s timeout)"
PAIRING_CODE=""
for i in $(seq 1 15); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
already_authenticated)
log "Already authenticated"
emit_status "already_authenticated" "success"
exit 0
;;
pairing_code:*)
PAIRING_CODE="${STATUS_CONTENT#pairing_code:}"
log "Got pairing code: $PAIRING_CODE"
break
;;
failed:*)
log "Auth failed early: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}"
exit 1
;;
esac
fi
sleep 1
done
if [ -z "$PAIRING_CODE" ]; then
log "Timeout waiting for pairing code"
emit_status "failed" "failed" "pairing_code_timeout"
exit 3
fi
# Poll for completion (120s, 2s intervals)
log "Polling for auth completion (120s timeout)"
for i in $(seq 1 60); do
if [ -f "$PROJECT_ROOT/store/auth-status.txt" ]; then
STATUS_CONTENT=$(cat "$PROJECT_ROOT/store/auth-status.txt" 2>/dev/null || echo "")
case "$STATUS_CONTENT" in
authenticated|already_authenticated)
log "Authentication successful: $STATUS_CONTENT"
emit_status "$STATUS_CONTENT" "success" "" "$PAIRING_CODE"
exit 0
;;
failed:logged_out)
log "Auth failed: logged out"
emit_status "failed" "failed" "logged_out" "$PAIRING_CODE"
exit 1
;;
failed:*)
log "Auth failed: $STATUS_CONTENT"
emit_status "failed" "failed" "${STATUS_CONTENT#failed:}" "$PAIRING_CODE"
exit 1
;;
esac
fi
sleep 2
done
log "Timeout waiting for auth completion"
emit_status "failed" "failed" "timeout" "$PAIRING_CODE"
exit 3
;;
qr-terminal)
log "QR terminal method selected — manual flow"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: qr-terminal
AUTH_STATUS: manual
PROJECT_PATH: $PROJECT_ROOT
STATUS: manual
LOG: logs/setup.log
=== END ===
EOF
exit 0
;;
*)
log "Unknown auth method: $METHOD"
cat <<EOF
=== NANOCLAW SETUP: AUTH_WHATSAPP ===
AUTH_METHOD: $METHOD
AUTH_STATUS: failed
STATUS: failed
ERROR: unknown_method
LOG: logs/setup.log
=== END ===
EOF
exit 4
;;
esac

View File

@@ -1,141 +0,0 @@
#!/bin/bash
set -euo pipefail
# 05-sync-groups.sh — Connect to WhatsApp, fetch group metadata, write to DB, exit.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [sync-groups] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Build TypeScript
log "Building TypeScript"
BUILD="failed"
if npm run build >> "$LOG_FILE" 2>&1; then
BUILD="success"
log "Build succeeded"
else
log "Build failed"
cat <<EOF
=== NANOCLAW SETUP: SYNC_GROUPS ===
BUILD: failed
SYNC: skipped
GROUPS_IN_DB: 0
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Directly connect, fetch groups, write to DB, exit
log "Fetching group metadata directly"
SYNC="failed"
SYNC_OUTPUT=$(node -e "
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
// Timeout after 30s
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
" --input-type=module 2>&1) || true
log "Sync output: $SYNC_OUTPUT"
if echo "$SYNC_OUTPUT" | grep -q "SYNCED:"; then
SYNC="success"
fi
# Check for groups in DB
GROUPS_IN_DB=0
if [ -f "$PROJECT_ROOT/store/messages.db" ]; then
GROUPS_IN_DB=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'" 2>/dev/null || echo "0")
log "Groups found in DB: $GROUPS_IN_DB"
fi
STATUS="success"
if [ "$SYNC" != "success" ]; then
STATUS="failed"
fi
cat <<EOF
=== NANOCLAW SETUP: SYNC_GROUPS ===
BUILD: $BUILD
SYNC: $SYNC
GROUPS_IN_DB: $GROUPS_IN_DB
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -euo pipefail
# 05b-list-groups.sh — Query WhatsApp groups from the database.
# Output: pipe-separated JID|name lines, most recent first.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
DB_PATH="$PROJECT_ROOT/store/messages.db"
LIMIT="${1:-30}"
if [ ! -f "$DB_PATH" ]; then
echo "ERROR: database not found" >&2
exit 1
fi
sqlite3 "$DB_PATH" "SELECT jid, name FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid ORDER BY last_message_time DESC LIMIT $LIMIT"

View File

@@ -1,106 +0,0 @@
#!/bin/bash
set -euo pipefail
# 06-register-channel.sh — Write channel registration config, create group folders
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [register-channel] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
JID=""
NAME=""
TRIGGER=""
FOLDER=""
REQUIRES_TRIGGER="true"
ASSISTANT_NAME="Andy"
while [[ $# -gt 0 ]]; do
case $1 in
--jid) JID="$2"; shift 2 ;;
--name) NAME="$2"; shift 2 ;;
--trigger) TRIGGER="$2"; shift 2 ;;
--folder) FOLDER="$2"; shift 2 ;;
--no-trigger-required) REQUIRES_TRIGGER="false"; shift ;;
--assistant-name) ASSISTANT_NAME="$2"; shift 2 ;;
*) shift ;;
esac
done
# Validate required args
if [ -z "$JID" ] || [ -z "$NAME" ] || [ -z "$TRIGGER" ] || [ -z "$FOLDER" ]; then
log "ERROR: Missing required args (--jid, --name, --trigger, --folder)"
cat <<EOF
=== NANOCLAW SETUP: REGISTER_CHANNEL ===
STATUS: failed
ERROR: missing_required_args
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
log "Registering channel: jid=$JID name=$NAME trigger=$TRIGGER folder=$FOLDER requiresTrigger=$REQUIRES_TRIGGER"
# Create data directory
mkdir -p "$PROJECT_ROOT/data"
# Write directly to SQLite (the DB and schema exist from the sync-groups step)
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')
DB_PATH="$PROJECT_ROOT/store/messages.db"
REQUIRES_TRIGGER_INT=$( [ "$REQUIRES_TRIGGER" = "true" ] && echo 1 || echo 0 )
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES ('$JID', '$NAME', '$FOLDER', '$TRIGGER', '$TIMESTAMP', NULL, $REQUIRES_TRIGGER_INT);"
log "Wrote registration to SQLite"
# Create group folders
mkdir -p "$PROJECT_ROOT/groups/$FOLDER/logs"
log "Created groups/$FOLDER/logs/"
# Update assistant name in CLAUDE.md files if different from default
NAME_UPDATED="false"
if [ "$ASSISTANT_NAME" != "Andy" ]; then
log "Updating assistant name from Andy to $ASSISTANT_NAME"
for md_file in groups/global/CLAUDE.md groups/main/CLAUDE.md; do
if [ -f "$PROJECT_ROOT/$md_file" ]; then
sed -i '' "s/^# Andy$/# $ASSISTANT_NAME/" "$PROJECT_ROOT/$md_file"
sed -i '' "s/You are Andy/You are $ASSISTANT_NAME/g" "$PROJECT_ROOT/$md_file"
log "Updated $md_file"
else
log "WARNING: $md_file not found, skipping name update"
fi
done
# Add ASSISTANT_NAME to .env so config.ts picks it up
ENV_FILE="$PROJECT_ROOT/.env"
if [ -f "$ENV_FILE" ] && grep -q '^ASSISTANT_NAME=' "$ENV_FILE"; then
sed "s|^ASSISTANT_NAME=.*|ASSISTANT_NAME=\"$ASSISTANT_NAME\"|" "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "ASSISTANT_NAME=\"$ASSISTANT_NAME\"" >> "$ENV_FILE"
fi
log "Set ASSISTANT_NAME=$ASSISTANT_NAME in .env"
NAME_UPDATED="true"
fi
cat <<EOF
=== NANOCLAW SETUP: REGISTER_CHANNEL ===
JID: $JID
NAME: $NAME
FOLDER: $FOLDER
TRIGGER: $TRIGGER
REQUIRES_TRIGGER: $REQUIRES_TRIGGER
ASSISTANT_NAME: $ASSISTANT_NAME
NAME_UPDATED: $NAME_UPDATED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,80 +0,0 @@
#!/bin/bash
set -euo pipefail
# 07-configure-mounts.sh — Write mount allowlist config file
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [configure-mounts] $*" >> "$LOG_FILE"; }
CONFIG_DIR="$HOME/.config/nanoclaw"
CONFIG_FILE="$CONFIG_DIR/mount-allowlist.json"
# Parse args
EMPTY_MODE="false"
while [[ $# -gt 0 ]]; do
case $1 in
--empty) EMPTY_MODE="true"; shift ;;
*) shift ;;
esac
done
# Create config directory
mkdir -p "$CONFIG_DIR"
log "Ensured config directory: $CONFIG_DIR"
if [ "$EMPTY_MODE" = "true" ]; then
log "Writing empty mount allowlist"
cat > "$CONFIG_FILE" <<'JSONEOF'
{
"allowedRoots": [],
"blockedPatterns": [],
"nonMainReadOnly": true
}
JSONEOF
ALLOWED_ROOTS=0
NON_MAIN_READ_ONLY="true"
else
# Read JSON from stdin
log "Reading mount allowlist from stdin"
INPUT=$(cat)
# Validate JSON
if ! echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{JSON.parse(d)}catch(e){process.exit(1)}})" 2>/dev/null; then
log "ERROR: Invalid JSON input"
cat <<EOF
=== NANOCLAW SETUP: CONFIGURE_MOUNTS ===
PATH: $CONFIG_FILE
ALLOWED_ROOTS: 0
NON_MAIN_READ_ONLY: unknown
STATUS: failed
ERROR: invalid_json
LOG: logs/setup.log
=== END ===
EOF
exit 4
fi
echo "$INPUT" > "$CONFIG_FILE"
log "Wrote mount allowlist from stdin"
# Extract values
ALLOWED_ROOTS=$(node -e "const d=require('$CONFIG_FILE');console.log((d.allowedRoots||[]).length)" 2>/dev/null || echo "0")
NON_MAIN_READ_ONLY=$(node -e "const d=require('$CONFIG_FILE');console.log(d.nonMainReadOnly===false?'false':'true')" 2>/dev/null || echo "true")
fi
log "Allowlist configured: $ALLOWED_ROOTS roots, nonMainReadOnly=$NON_MAIN_READ_ONLY"
cat <<EOF
=== NANOCLAW SETUP: CONFIGURE_MOUNTS ===
PATH: $CONFIG_FILE
ALLOWED_ROOTS: $ALLOWED_ROOTS
NON_MAIN_READ_ONLY: $NON_MAIN_READ_ONLY
STATUS: success
LOG: logs/setup.log
=== END ===
EOF

View File

@@ -1,197 +0,0 @@
#!/bin/bash
set -euo pipefail
# 08-setup-service.sh — Generate and load service manager config
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [setup-service] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
# Parse args
PLATFORM=""
while [[ $# -gt 0 ]]; do
case $1 in
--platform) PLATFORM="$2"; shift 2 ;;
*) shift ;;
esac
done
# Auto-detect platform
if [ -z "$PLATFORM" ]; then
case "$(uname -s)" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
fi
NODE_PATH=$(which node)
PROJECT_PATH="$PROJECT_ROOT"
HOME_PATH="$HOME"
log "Setting up service: platform=$PLATFORM node=$NODE_PATH project=$PROJECT_PATH"
# Build first
log "Building TypeScript"
if ! npm run build >> "$LOG_FILE" 2>&1; then
log "Build failed"
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: unknown
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
STATUS: failed
ERROR: build_failed
LOG: logs/setup.log
=== END ===
EOF
exit 1
fi
# Create logs directory
mkdir -p "$PROJECT_PATH/logs"
case "$PLATFORM" in
macos)
PLIST_PATH="$HOME_PATH/Library/LaunchAgents/com.nanoclaw.plist"
log "Generating launchd plist at $PLIST_PATH"
mkdir -p "$HOME_PATH/Library/LaunchAgents"
cat > "$PLIST_PATH" <<PLISTEOF
<?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_PATH}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${PROJECT_PATH}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin</string>
<key>HOME</key>
<string>${HOME_PATH}</string>
</dict>
<key>StandardOutPath</key>
<string>${PROJECT_PATH}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${PROJECT_PATH}/logs/nanoclaw.error.log</string>
</dict>
</plist>
PLISTEOF
log "Loading launchd service"
if launchctl load "$PLIST_PATH" >> "$LOG_FILE" 2>&1; then
log "launchctl load succeeded"
else
log "launchctl load failed (may already be loaded)"
fi
# Verify
SERVICE_LOADED="false"
if launchctl list 2>/dev/null | grep -q "com.nanoclaw"; then
SERVICE_LOADED="true"
log "Service verified as loaded"
else
log "Service not found in launchctl list"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: launchd
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
PLIST_PATH: $PLIST_PATH
SERVICE_LOADED: $SERVICE_LOADED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
;;
linux)
UNIT_DIR="$HOME_PATH/.config/systemd/user"
UNIT_PATH="$UNIT_DIR/nanoclaw.service"
mkdir -p "$UNIT_DIR"
log "Generating systemd unit at $UNIT_PATH"
cat > "$UNIT_PATH" <<UNITEOF
[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${NODE_PATH} ${PROJECT_PATH}/dist/index.js
WorkingDirectory=${PROJECT_PATH}
Restart=always
RestartSec=5
Environment=HOME=${HOME_PATH}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin
StandardOutput=append:${PROJECT_PATH}/logs/nanoclaw.log
StandardError=append:${PROJECT_PATH}/logs/nanoclaw.error.log
[Install]
WantedBy=default.target
UNITEOF
log "Enabling and starting systemd service"
systemctl --user daemon-reload >> "$LOG_FILE" 2>&1 || true
systemctl --user enable nanoclaw >> "$LOG_FILE" 2>&1 || true
systemctl --user start nanoclaw >> "$LOG_FILE" 2>&1 || true
# Verify
SERVICE_LOADED="false"
if systemctl --user is-active nanoclaw >/dev/null 2>&1; then
SERVICE_LOADED="true"
log "Service verified as active"
else
log "Service not active"
fi
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: systemd
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
UNIT_PATH: $UNIT_PATH
SERVICE_LOADED: $SERVICE_LOADED
STATUS: success
LOG: logs/setup.log
=== END ===
EOF
;;
*)
log "Unsupported platform: $PLATFORM"
cat <<EOF
=== NANOCLAW SETUP: SETUP_SERVICE ===
SERVICE_TYPE: unknown
NODE_PATH: $NODE_PATH
PROJECT_PATH: $PROJECT_PATH
STATUS: failed
ERROR: unsupported_platform
LOG: logs/setup.log
=== END ===
EOF
exit 1
;;
esac

View File

@@ -1,109 +0,0 @@
#!/bin/bash
set -euo pipefail
# 09-verify.sh — End-to-end health check of the full installation
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [verify] $*" >> "$LOG_FILE"; }
cd "$PROJECT_ROOT"
log "Starting verification"
# Detect platform
case "$(uname -s)" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
# 1. Check service status
SERVICE="not_found"
if [ "$PLATFORM" = "macos" ]; then
if launchctl list 2>/dev/null | grep -q "com.nanoclaw"; then
# Check if it has a PID (actually running)
LAUNCHCTL_LINE=$(launchctl list 2>/dev/null | grep "com.nanoclaw" || true)
PID_FIELD=$(echo "$LAUNCHCTL_LINE" | awk '{print $1}')
if [ "$PID_FIELD" != "-" ] && [ -n "$PID_FIELD" ]; then
SERVICE="running"
else
SERVICE="stopped"
fi
fi
elif [ "$PLATFORM" = "linux" ]; then
if systemctl --user is-active nanoclaw >/dev/null 2>&1; then
SERVICE="running"
elif systemctl --user list-unit-files 2>/dev/null | grep -q "nanoclaw"; then
SERVICE="stopped"
fi
fi
log "Service: $SERVICE"
# 2. Check container runtime
CONTAINER_RUNTIME="none"
if command -v container >/dev/null 2>&1; then
CONTAINER_RUNTIME="apple-container"
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINER_RUNTIME="docker"
fi
log "Container runtime: $CONTAINER_RUNTIME"
# 3. Check credentials
CREDENTIALS="missing"
if [ -f "$PROJECT_ROOT/.env" ]; then
if grep -qE "^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=" "$PROJECT_ROOT/.env" 2>/dev/null; then
CREDENTIALS="configured"
fi
fi
log "Credentials: $CREDENTIALS"
# 4. Check WhatsApp auth
WHATSAPP_AUTH="not_found"
if [ -d "$PROJECT_ROOT/store/auth" ] && [ "$(ls -A "$PROJECT_ROOT/store/auth" 2>/dev/null)" ]; then
WHATSAPP_AUTH="authenticated"
fi
log "WhatsApp auth: $WHATSAPP_AUTH"
# 5. Check registered groups (in SQLite — the JSON file gets migrated away on startup)
REGISTERED_GROUPS=0
if [ -f "$PROJECT_ROOT/store/messages.db" ]; then
REGISTERED_GROUPS=$(sqlite3 "$PROJECT_ROOT/store/messages.db" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo "0")
fi
log "Registered groups: $REGISTERED_GROUPS"
# 6. Check mount allowlist
MOUNT_ALLOWLIST="missing"
if [ -f "$HOME/.config/nanoclaw/mount-allowlist.json" ]; then
MOUNT_ALLOWLIST="configured"
fi
log "Mount allowlist: $MOUNT_ALLOWLIST"
# Determine overall status
STATUS="success"
if [ "$SERVICE" != "running" ] || [ "$CREDENTIALS" = "missing" ] || [ "$WHATSAPP_AUTH" = "not_found" ] || [ "$REGISTERED_GROUPS" -eq 0 ] 2>/dev/null; then
STATUS="failed"
fi
log "Verification complete: $STATUS"
cat <<EOF
=== NANOCLAW SETUP: VERIFY ===
SERVICE: $SERVICE
CONTAINER_RUNTIME: $CONTAINER_RUNTIME
CREDENTIALS: $CREDENTIALS
WHATSAPP_AUTH: $WHATSAPP_AUTH
REGISTERED_GROUPS: $REGISTERED_GROUPS
MOUNT_ALLOWLIST: $MOUNT_ALLOWLIST
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
if [ "$STATUS" = "failed" ]; then
exit 1
fi

View File

@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings → Linked Devices → Link a Device</div>
</div>
<script>
// Persist start time across auto-refreshes
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired — a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>

View File

@@ -49,8 +49,9 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
# 3. Rebuild host and restart service
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Verify: launchctl list | grep nanoclaw shows PID and exit code 0
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
```
## Configuration
@@ -271,12 +272,14 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
**Verify success:**
```bash
launchctl list | grep nanoclaw # Should show PID and exit code 0 or -
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
```
## Usage via WhatsApp
@@ -342,7 +345,8 @@ echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts
```bash
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Browser Lock Files

View File

@@ -41,8 +41,15 @@ npm run build # Compile TypeScript
Service management:
```bash
# macOS (launchd)
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
# Linux (systemd)
systemctl --user start nanoclaw
systemctl --user stop nanoclaw
systemctl --user restart nanoclaw
```
## Container Build Cache

View File

@@ -12,6 +12,7 @@
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"setup": "tsx src/setup/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},

161
setup.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/bin/bash
set -euo pipefail
# setup.sh — Bootstrap script for NanoClaw
# Handles Node.js/npm setup, then hands off to the Node.js setup modules.
# This is the only bash script in the setup flow.
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
mkdir -p "$PROJECT_ROOT/logs"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; }
# --- Platform detection ---
detect_platform() {
local uname_s
uname_s=$(uname -s)
case "$uname_s" in
Darwin*) PLATFORM="macos" ;;
Linux*) PLATFORM="linux" ;;
*) PLATFORM="unknown" ;;
esac
IS_WSL="false"
if [ "$PLATFORM" = "linux" ] && [ -f /proc/version ]; then
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
IS_WSL="true"
fi
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
log "Platform: $PLATFORM, WSL: $IS_WSL, Root: $IS_ROOT"
}
# --- Node.js check ---
check_node() {
NODE_OK="false"
NODE_VERSION="not_found"
NODE_PATH_FOUND=""
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version 2>/dev/null | sed 's/^v//')
NODE_PATH_FOUND=$(command -v node)
local major
major=$(echo "$NODE_VERSION" | cut -d. -f1)
if [ "$major" -ge 20 ] 2>/dev/null; then
NODE_OK="true"
fi
log "Node $NODE_VERSION at $NODE_PATH_FOUND (major=$major, ok=$NODE_OK)"
else
log "Node not found"
fi
}
# --- npm install ---
install_deps() {
DEPS_OK="false"
NATIVE_OK="false"
if [ "$NODE_OK" = "false" ]; then
log "Skipping npm install — Node not available"
return
fi
cd "$PROJECT_ROOT"
# npm install with --unsafe-perm if root (needed for native modules)
local npm_flags=""
if [ "$IS_ROOT" = "true" ]; then
npm_flags="--unsafe-perm"
log "Running as root, using --unsafe-perm"
fi
log "Running npm install $npm_flags"
if npm install $npm_flags >> "$LOG_FILE" 2>&1; then
DEPS_OK="true"
log "npm install succeeded"
else
log "npm install failed"
return
fi
# Verify native module (better-sqlite3)
log "Verifying native modules"
if node -e "require('better-sqlite3')" >> "$LOG_FILE" 2>&1; then
NATIVE_OK="true"
log "better-sqlite3 loads OK"
else
log "better-sqlite3 failed to load"
fi
}
# --- Build tools check ---
check_build_tools() {
HAS_BUILD_TOOLS="false"
if [ "$PLATFORM" = "macos" ]; then
if xcode-select -p >/dev/null 2>&1; then
HAS_BUILD_TOOLS="true"
fi
elif [ "$PLATFORM" = "linux" ]; then
if command -v gcc >/dev/null 2>&1 && command -v make >/dev/null 2>&1; then
HAS_BUILD_TOOLS="true"
fi
fi
log "Build tools: $HAS_BUILD_TOOLS"
}
# --- Main ---
log "=== Bootstrap started ==="
detect_platform
check_node
install_deps
check_build_tools
# Emit status block
STATUS="success"
if [ "$NODE_OK" = "false" ]; then
STATUS="node_missing"
elif [ "$DEPS_OK" = "false" ]; then
STATUS="deps_failed"
elif [ "$NATIVE_OK" = "false" ]; then
STATUS="native_failed"
fi
cat <<EOF
=== NANOCLAW SETUP: BOOTSTRAP ===
PLATFORM: $PLATFORM
IS_WSL: $IS_WSL
IS_ROOT: $IS_ROOT
NODE_VERSION: $NODE_VERSION
NODE_OK: $NODE_OK
NODE_PATH: ${NODE_PATH_FOUND:-not_found}
DEPS_OK: $DEPS_OK
NATIVE_OK: $NATIVE_OK
HAS_BUILD_TOOLS: $HAS_BUILD_TOOLS
STATUS: $STATUS
LOG: logs/setup.log
=== END ===
EOF
log "=== Bootstrap completed: $STATUS ==="
if [ "$NODE_OK" = "false" ]; then
exit 2
fi
if [ "$DEPS_OK" = "false" ] || [ "$NATIVE_OK" = "false" ]; then
exit 1
fi

123
src/setup/container.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* Step: container — Build container image and verify with test run.
* Replaces 03-setup-container.sh
*/
import { execSync } from 'child_process';
import path from 'path';
import { logger } from '../logger.js';
import { commandExists } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { runtime: string } {
let runtime = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--runtime' && args[i + 1]) {
runtime = args[i + 1];
i++;
}
}
return { runtime };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = 'nanoclaw-agent:latest';
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (!runtime) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: 'unknown',
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'missing_runtime_flag',
LOG: 'logs/setup.log',
});
process.exit(4);
}
// Validate runtime availability
if (runtime === 'apple-container' && !commandExists('container')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
if (runtime === 'docker') {
if (!commandExists('docker')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
try {
execSync('docker info', { stdio: 'ignore' });
} catch {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
}
if (!['apple-container', 'docker'].includes(runtime)) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log',
});
process.exit(4);
}
const buildCmd = runtime === 'apple-container' ? 'container build' : 'docker build';
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
// Build
let buildOk = false;
logger.info({ runtime }, 'Building container');
try {
execSync(`${buildCmd} -t ${image} .`, {
cwd: path.join(projectRoot, 'container'),
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Container build succeeded');
} catch (err) {
logger.error({ err }, 'Container build failed');
}
// Test
let testOk = false;
if (buildOk) {
logger.info('Testing container');
try {
const output = execSync(
`echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
testOk = output.includes('Container OK');
logger.info({ testOk }, 'Container test result');
} catch {
logger.error('Container test failed');
}
}
const status = buildOk && testOk ? 'success' : 'failed';
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: buildOk,
TEST_OK: testOk,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import Database from 'better-sqlite3';
/**
* Tests for the environment check step.
*
* Verifies: config detection, Docker/AC detection, DB queries.
*/
describe('environment detection', () => {
it('detects platform correctly', async () => {
const { getPlatform } = await import('./platform.js');
const platform = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(platform);
});
});
describe('registered groups DB query', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
});
it('returns 0 for empty table', () => {
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(0);
});
it('returns correct count after inserts', () => {
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('123@g.us', 'Group 1', 'group-1', '@Andy', '2024-01-01T00:00:00.000Z', 1);
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('456@g.us', 'Group 2', 'group-2', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(2);
});
});
describe('credentials detection', () => {
it('detects ANTHROPIC_API_KEY in env content', () => {
const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('returns false when no credentials', () => {
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(false);
});
});
describe('Docker detection logic', () => {
it('commandExists returns boolean', async () => {
const { commandExists } = await import('./platform.js');
expect(typeof commandExists('docker')).toBe('boolean');
expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean');
});
});
describe('WhatsApp auth detection', () => {
it('detects non-empty auth directory logic', () => {
// Simulate the check: directory exists and has files
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
} catch {
return false;
}
};
// Non-existent directory
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});

86
src/setup/environment.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Step: environment — Detect OS, Node, container runtimes, existing config.
* Replaces 01-check-environment.sh
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
logger.info('Starting environment check');
const platform = getPlatform();
const wsl = isWSL();
const headless = isHeadless();
// Check Apple Container
let appleContainer: 'installed' | 'not_found' = 'not_found';
if (commandExists('container')) {
appleContainer = 'installed';
}
// Check Docker
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
if (commandExists('docker')) {
try {
const { execSync } = await import('child_process');
execSync('docker info', { stdio: 'ignore' });
docker = 'running';
} catch {
docker = 'installed_not_running';
}
}
// Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth =
fs.existsSync(authDir) &&
fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
logger.info({ platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups },
'Environment check complete');
emitStatus('CHECK_ENVIRONMENT', {
PLATFORM: platform,
IS_WSL: wsl,
IS_HEADLESS: headless,
APPLE_CONTAINER: appleContainer,
DOCKER: docker,
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

195
src/setup/groups.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Step: groups — Connect to WhatsApp, fetch group metadata, write to DB.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; }
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
).all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Build TypeScript first
logger.info('Building TypeScript');
let buildOk = false;
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run inline sync script via node
logger.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const output = execSync(`node --input-type=module -e ${JSON.stringify(syncScript)}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
logger.info({ output: output.trim() }, 'Sync output');
} catch (err) {
logger.error({ err }, 'Sync failed');
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
).get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

52
src/setup/index.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Setup CLI entry point.
* Usage: npx tsx src/setup/index.ts --step <name> [args...]
*/
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
const STEPS: Record<string, () => Promise<{ run: (args: string[]) => Promise<void> }>> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};
async function main(): Promise<void> {
const args = process.argv.slice(2);
const stepIdx = args.indexOf('--step');
if (stepIdx === -1 || !args[stepIdx + 1]) {
console.error(`Usage: npx tsx src/setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`);
process.exit(1);
}
const stepName = args[stepIdx + 1];
const stepArgs = args.filter((a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--');
const loader = STEPS[stepName];
if (!loader) {
console.error(`Unknown step: ${stepName}`);
console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
process.exit(1);
}
try {
const mod = await loader();
await mod.run(stepArgs);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, step: stepName }, 'Setup step failed');
emitStatus(stepName.toUpperCase(), {
STATUS: 'failed',
ERROR: message,
});
process.exit(1);
}
}
main();

103
src/setup/mounts.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Step: mounts — Write mount allowlist config file.
* Replaces 07-configure-mounts.sh
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../logger.js';
import { isRoot } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { empty: boolean; json: string } {
let empty = false;
let json = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--empty') empty = true;
if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; }
}
return { empty, json };
}
export async function run(args: string[]): Promise<void> {
const { empty, json } = parseArgs(args);
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.config', 'nanoclaw');
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {
logger.warn('Running as root — mount allowlist will be written to root home directory');
}
fs.mkdirSync(configDir, { recursive: true });
let allowedRoots = 0;
let nonMainReadOnly = 'true';
if (empty) {
logger.info('Writing empty mount allowlist');
const emptyConfig = {
allowedRoots: [],
blockedPatterns: [],
nonMainReadOnly: true,
};
fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n');
} else if (json) {
// Validate JSON with JSON.parse (not piped through shell)
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(json);
} catch {
logger.error('Invalid JSON input');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return; // unreachable but satisfies TS
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
} else {
// Read from stdin
logger.info('Reading mount allowlist from stdin');
const input = fs.readFileSync(0, 'utf-8');
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(input);
} catch {
logger.error('Invalid JSON from stdin');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return;
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
}
logger.info({ configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: allowedRoots,
NON_MAIN_READ_ONLY: nonMainReadOnly,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

121
src/setup/platform.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import {
getPlatform,
isWSL,
isRoot,
isHeadless,
hasSystemd,
getServiceManager,
commandExists,
getNodeVersion,
getNodeMajorVersion,
} from './platform.js';
// --- getPlatform ---
describe('getPlatform', () => {
it('returns a valid platform string', () => {
const result = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(result);
});
it('returns linux on this system', () => {
// This test runs on Linux
expect(getPlatform()).toBe('linux');
});
});
// --- isWSL ---
describe('isWSL', () => {
it('returns a boolean', () => {
expect(typeof isWSL()).toBe('boolean');
});
it('checks /proc/version for WSL markers', () => {
// On non-WSL Linux, should return false
// On WSL, should return true
// Just verify it doesn't throw
const result = isWSL();
expect(typeof result).toBe('boolean');
});
});
// --- isRoot ---
describe('isRoot', () => {
it('returns a boolean', () => {
expect(typeof isRoot()).toBe('boolean');
});
});
// --- isHeadless ---
describe('isHeadless', () => {
it('returns a boolean', () => {
expect(typeof isHeadless()).toBe('boolean');
});
});
// --- hasSystemd ---
describe('hasSystemd', () => {
it('returns a boolean', () => {
expect(typeof hasSystemd()).toBe('boolean');
});
it('checks /proc/1/comm', () => {
// On systemd systems, should return true
// Just verify it doesn't throw
const result = hasSystemd();
expect(typeof result).toBe('boolean');
});
});
// --- getServiceManager ---
describe('getServiceManager', () => {
it('returns a valid service manager', () => {
const result = getServiceManager();
expect(['launchd', 'systemd', 'none']).toContain(result);
});
it('returns systemd or none on Linux', () => {
const result = getServiceManager();
// On Linux, should be systemd if available, else none
expect(['systemd', 'none']).toContain(result);
});
});
// --- commandExists ---
describe('commandExists', () => {
it('returns true for node', () => {
expect(commandExists('node')).toBe(true);
});
it('returns false for nonexistent command', () => {
expect(commandExists('this_command_does_not_exist_xyz_123')).toBe(false);
});
});
// --- getNodeVersion ---
describe('getNodeVersion', () => {
it('returns a version string', () => {
const version = getNodeVersion();
expect(version).not.toBeNull();
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
});
// --- getNodeMajorVersion ---
describe('getNodeMajorVersion', () => {
it('returns at least 20', () => {
const major = getNodeMajorVersion();
expect(major).not.toBeNull();
expect(major!).toBeGreaterThanOrEqual(20);
});
});

130
src/setup/platform.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Cross-platform detection utilities for NanoClaw setup.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
export type Platform = 'macos' | 'linux' | 'unknown';
export type ServiceManager = 'launchd' | 'systemd' | 'none';
export function getPlatform(): Platform {
const platform = os.platform();
if (platform === 'darwin') return 'macos';
if (platform === 'linux') return 'linux';
return 'unknown';
}
export function isWSL(): boolean {
if (os.platform() !== 'linux') return false;
try {
const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return release.includes('microsoft') || release.includes('wsl');
} catch {
return false;
}
}
export function isRoot(): boolean {
return process.getuid?.() === 0;
}
export function isHeadless(): boolean {
// No display server available
if (getPlatform() === 'linux') {
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
}
// macOS is never headless in practice (even SSH sessions can open URLs)
return false;
}
export function hasSystemd(): boolean {
if (getPlatform() !== 'linux') return false;
try {
// Check if systemd is PID 1
const init = fs.readFileSync('/proc/1/comm', 'utf-8').trim();
return init === 'systemd';
} catch {
return false;
}
}
/**
* Open a URL in the default browser, cross-platform.
* Returns true if the command was attempted, false if no method available.
*/
export function openBrowser(url: string): boolean {
try {
const platform = getPlatform();
if (platform === 'macos') {
execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (platform === 'linux') {
// Try xdg-open first, then wslview for WSL
if (commandExists('xdg-open')) {
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (isWSL() && commandExists('wslview')) {
execSync(`wslview ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
// WSL without wslview: try cmd.exe
if (isWSL()) {
try {
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
} catch {
// cmd.exe not available
}
}
}
} catch {
// Command failed
}
return false;
}
export function getServiceManager(): ServiceManager {
const platform = getPlatform();
if (platform === 'macos') return 'launchd';
if (platform === 'linux') {
if (hasSystemd()) return 'systemd';
return 'none';
}
return 'none';
}
export function getNodePath(): string {
try {
return execSync('command -v node', { encoding: 'utf-8' }).trim();
} catch {
return process.execPath;
}
}
export function commandExists(name: string): boolean {
try {
execSync(`command -v ${name}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
export function getNodeVersion(): string | null {
try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim();
return version.replace(/^v/, '');
} catch {
return null;
}
}
export function getNodeMajorVersion(): number | null {
const version = getNodeVersion();
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return isNaN(major) ? null : major;
}

165
src/setup/register.test.ts Normal file
View File

@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
/**
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
*/
function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
return db;
}
describe('parameterized SQL registration', () => {
let db: Database.Database;
beforeEach(() => {
db = createTestDb();
});
it('registers a group with parameterized query', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get('123@g.us') as {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
requires_trigger: number;
};
expect(row.jid).toBe('123@g.us');
expect(row.name).toBe('Test Group');
expect(row.folder).toBe('test-group');
expect(row.trigger_pattern).toBe('@Andy');
expect(row.requires_trigger).toBe(1);
});
it('handles apostrophes in group names safely', () => {
const name = "O'Brien's Group";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT name FROM registered_groups WHERE jid = ?').get('456@g.us') as {
name: string;
};
expect(row.name).toBe(name);
});
it('prevents SQL injection in JID field', () => {
const maliciousJid = "'; DROP TABLE registered_groups; --";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
// Table should still exist and have the row
const count = db.prepare('SELECT COUNT(*) as count FROM registered_groups').get() as {
count: number;
};
expect(count.count).toBe(1);
const row = db.prepare('SELECT jid FROM registered_groups').get() as { jid: string };
expect(row.jid).toBe(maliciousJid);
});
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);
stmt.run('123@g.us', 'Original', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 1);
stmt.run('123@g.us', 'Updated', 'main', '@Bot', '2024-02-01T00:00:00.000Z', 0);
const rows = db.prepare('SELECT * FROM registered_groups').all();
expect(rows).toHaveLength(1);
const row = rows[0] as { name: string; trigger_pattern: string; requires_trigger: number };
expect(row.name).toBe('Updated');
expect(row.trigger_pattern).toBe('@Bot');
expect(row.requires_trigger).toBe(0);
});
});
describe('file templating', () => {
it('replaces assistant name in CLAUDE.md content', () => {
let content = '# Andy\n\nYou are Andy, a personal assistant.';
content = content.replace(/^# Andy$/m, '# Nova');
content = content.replace(/You are Andy/g, 'You are Nova');
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
});
it('handles names with special regex characters', () => {
let content = '# Andy\n\nYou are Andy.';
const newName = 'C.L.A.U.D.E';
content = content.replace(/^# Andy$/m, `# ${newName}`);
content = content.replace(/You are Andy/g, `You are ${newName}`);
expect(content).toContain('# C.L.A.U.D.E');
expect(content).toContain('You are C.L.A.U.D.E.');
});
it('updates .env ASSISTANT_NAME line', () => {
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
'ASSISTANT_NAME="Nova"',
);
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
expect(envContent).toContain('SOME_KEY=value');
});
it('appends ASSISTANT_NAME to .env if not present', () => {
let envContent = 'SOME_KEY=value\n';
if (!envContent.includes('ASSISTANT_NAME=')) {
envContent += '\nASSISTANT_NAME="Nova"';
}
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});

147
src/setup/register.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Step: register — Write channel registration config, create group folders.
* Replaces 06-register-channel.sh
*
* Fixes: SQL injection (parameterized queries), sed -i '' (uses fs directly).
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
interface RegisterArgs {
jid: string;
name: string;
trigger: string;
folder: string;
requiresTrigger: boolean;
assistantName: string;
}
function parseArgs(args: string[]): RegisterArgs {
const result: RegisterArgs = {
jid: '',
name: '',
trigger: '',
folder: '',
requiresTrigger: true,
assistantName: 'Andy',
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--jid': result.jid = args[++i] || ''; break;
case '--name': result.name = args[++i] || ''; break;
case '--trigger': result.trigger = args[++i] || ''; break;
case '--folder': result.folder = args[++i] || ''; break;
case '--no-trigger-required': result.requiresTrigger = false; break;
case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break;
}
}
return result;
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const parsed = parseArgs(args);
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'missing_required_args',
LOG: 'logs/setup.log',
});
process.exit(4);
}
logger.info(parsed, 'Registering channel');
// Ensure data directory exists
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
// Write to SQLite using parameterized queries (no SQL injection)
const dbPath = path.join(STORE_DIR, 'messages.db');
const timestamp = new Date().toISOString();
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(parsed.jid, parsed.name, parsed.folder, parsed.trigger, timestamp, requiresTriggerInt);
db.close();
logger.info('Wrote registration to SQLite');
// Create group folders
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
// Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false;
if (parsed.assistantName !== 'Andy') {
logger.info({ from: 'Andy', to: parsed.assistantName }, 'Updating assistant name');
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
];
for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
fs.writeFileSync(mdFile, content);
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
}
}
// Update .env
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
let envContent = fs.readFileSync(envFile, 'utf-8');
if (envContent.includes('ASSISTANT_NAME=')) {
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
`ASSISTANT_NAME="${parsed.assistantName}"`,
);
} else {
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
}
fs.writeFileSync(envFile, envContent);
} else {
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
}
logger.info('Set ASSISTANT_NAME in .env');
nameUpdated = true;
}
emitStatus('REGISTER_CHANNEL', {
JID: parsed.jid,
NAME: parsed.name,
FOLDER: parsed.folder,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
NAME_UPDATED: nameUpdated,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

134
src/setup/service.test.ts Normal file
View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
/**
* Tests for service configuration generation.
*
* These tests verify the generated content of plist/systemd/nohup configs
* without actually loading services.
*/
// Helper: generate a plist string the same way service.ts does
function generatePlist(nodePath: string, projectRoot: string, homeDir: string): string {
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>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
}
function generateSystemdUnit(
nodePath: string,
projectRoot: string,
homeDir: string,
isSystem: boolean,
): string {
return `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
}
describe('plist generation', () => {
it('contains the correct label', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>com.nanoclaw</string>');
});
it('uses the correct node path', () => {
const plist = generatePlist('/opt/node/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>/opt/node/bin/node</string>');
});
it('points to dist/index.js', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
});
it('sets log paths', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('nanoclaw.log');
expect(plist).toContain('nanoclaw.error.log');
});
});
describe('systemd unit generation', () => {
it('user unit uses default.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('WantedBy=default.target');
});
it('system unit uses multi-user.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', true);
expect(unit).toContain('WantedBy=multi-user.target');
});
it('contains restart policy', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('Restart=always');
expect(unit).toContain('RestartSec=5');
});
it('sets correct ExecStart', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/srv/nanoclaw', '/home/user', false);
expect(unit).toContain('ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js');
});
});
describe('WSL nohup fallback', () => {
it('generates a valid wrapper script', () => {
const projectRoot = '/home/user/nanoclaw';
const nodePath = '/usr/bin/node';
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
// Simulate what service.ts generates
const wrapper = `#!/bin/bash
set -euo pipefail
cd ${JSON.stringify(projectRoot)}
nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log &
echo $! > ${JSON.stringify(pidFile)}`;
expect(wrapper).toContain('#!/bin/bash');
expect(wrapper).toContain('nohup');
expect(wrapper).toContain(nodePath);
expect(wrapper).toContain('nanoclaw.pid');
});
});

336
src/setup/service.ts Normal file
View File

@@ -0,0 +1,336 @@
/**
* Step: service — Generate and load service manager config.
* Replaces 08-setup-service.sh
*
* Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from '../logger.js';
import {
getPlatform,
getNodePath,
getServiceManager,
hasSystemd,
isRoot,
isWSL,
} from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const nodePath = getNodePath();
const homeDir = os.homedir();
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
// Build first
logger.info('Building TypeScript');
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
if (platform === 'macos') {
setupLaunchd(projectRoot, nodePath, homeDir);
} else if (platform === 'linux') {
setupLinux(projectRoot, nodePath, homeDir);
} else {
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG: 'logs/setup.log',
});
process.exit(1);
}
}
function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): void {
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist');
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
const plist = `<?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>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
fs.writeFileSync(plistPath, plist);
logger.info({ plistPath }, 'Wrote launchd plist');
try {
execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore' });
logger.info('launchctl load succeeded');
} catch {
logger.warn('launchctl load failed (may already be loaded)');
}
// Verify
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes('com.nanoclaw');
} catch {
// launchctl list failed
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'launchd',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
PLIST_PATH: plistPath,
SERVICE_LOADED: serviceLoaded,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupLinux(projectRoot: string, nodePath: string, homeDir: string): void {
const serviceManager = getServiceManager();
if (serviceManager === 'systemd') {
setupSystemd(projectRoot, nodePath, homeDir);
} else {
// WSL without systemd or other Linux without systemd
setupNohupFallback(projectRoot, nodePath, homeDir);
}
}
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
* Prevents WhatsApp "conflict" disconnects when two instances connect simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
try {
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
stdio: 'ignore',
});
logger.info('Stopped any orphaned nanoclaw processes');
} catch {
// pkill not available or no orphans
}
}
/**
* Detect stale docker group membership in the user systemd session.
*
* When a user is added to the docker group mid-session, the user systemd
* daemon (user@UID.service) keeps the old group list from login time.
* Docker works in the terminal but not in the service context.
*
* Only relevant on Linux with user-level systemd (not root, not macOS, not WSL nohup).
*/
function checkDockerGroupStale(): boolean {
try {
execSync('systemd-run --user --pipe --wait docker info', {
stdio: 'pipe',
timeout: 10000,
});
return false; // Docker works from systemd session
} catch {
// Check if docker works from the current shell (to distinguish stale group vs broken docker)
try {
execSync('docker info', { stdio: 'pipe', timeout: 5000 });
return true; // Works in shell but not systemd session → stale group
} catch {
return false; // Docker itself is not working, different issue
}
}
}
function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): void {
const runningAsRoot = isRoot();
// Root uses system-level service, non-root uses user-level
let unitPath: string;
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
systemctlPrefix = 'systemctl';
logger.info('Running as root — installing system-level systemd unit');
} else {
// Check if user-level systemd session is available
try {
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
} catch {
logger.warn('systemd user session not available — falling back to nohup wrapper');
setupNohupFallback(projectRoot, nodePath, homeDir);
return;
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, 'nanoclaw.service');
systemctlPrefix = 'systemctl --user';
}
const unit = `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
logger.info({ unitPath }, 'Wrote systemd unit');
// Detect stale docker group before starting (user systemd only)
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
if (dockerGroupStale) {
logger.warn(
'Docker group not active in systemd session — user was likely added to docker group mid-session',
);
}
// Kill orphaned nanoclaw processes to avoid WhatsApp conflict errors
killOrphanedProcesses(projectRoot);
// Enable and start
try {
execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl daemon-reload failed');
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl enable failed');
}
try {
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl start failed');
}
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
serviceLoaded = true;
} catch {
// Not active
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
UNIT_PATH: unitPath,
SERVICE_LOADED: serviceLoaded,
...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}),
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupNohupFallback(projectRoot: string, nodePath: string, homeDir: string): void {
logger.warn('No systemd detected — generating nohup wrapper script');
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
const lines = [
'#!/bin/bash',
'# start-nanoclaw.sh — Start NanoClaw without systemd',
`# To stop: kill \\$(cat ${pidFile})`,
'',
'set -euo pipefail',
'',
`cd ${JSON.stringify(projectRoot)}`,
'',
'# Stop existing instance if running',
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
' echo "Stopping existing NanoClaw (PID $OLD_PID)..."',
' kill "$OLD_PID" 2>/dev/null || true',
' sleep 2',
' fi',
'fi',
'',
'echo "Starting NanoClaw..."',
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
'',
`echo $! > ${JSON.stringify(pidFile)}`,
'echo "NanoClaw started (PID $!)"',
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
];
const wrapper = lines.join('\n') + '\n';
fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
logger.info({ wrapperPath }, 'Wrote nohup wrapper script');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'nohup',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
WRAPPER_PATH: wrapperPath,
SERVICE_LOADED: false,
FALLBACK: 'wsl_no_systemd',
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

16
src/setup/status.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Structured status block output for setup steps.
* Each step emits a block that the SKILL.md LLM can parse.
*/
export function emitStatus(
step: string,
fields: Record<string, string | number | boolean>,
): void {
const lines = [`=== NANOCLAW SETUP: ${step} ===`];
for (const [key, value] of Object.entries(fields)) {
lines.push(`${key}: ${value}`);
}
lines.push('=== END ===');
console.log(lines.join('\n'));
}

152
src/setup/verify.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Step: verify — End-to-end health check of the full installation.
* Replaces 09-verify.sh
*
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const homeDir = os.homedir();
logger.info('Starting verification');
// 1. Check service status
let service = 'not_found';
const mgr = getServiceManager();
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes('com.nanoclaw')) {
// Check if it has a PID (actually running)
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
service = pidField !== '-' && pidField ? 'running' : 'stopped';
}
}
} catch {
// launchctl not available
}
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
service = 'running';
} catch {
try {
const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8' });
if (output.includes('nanoclaw')) {
service = 'stopped';
}
} catch {
// systemctl not available
}
}
} else {
// Check for nohup PID file
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
if (fs.existsSync(pidFile)) {
try {
const pid = fs.readFileSync(pidFile, 'utf-8').trim();
if (pid) {
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
service = 'running';
}
} catch {
service = 'stopped';
}
}
}
logger.info({ service }, 'Service status');
// 2. Check container runtime
let containerRuntime = 'none';
try {
execSync('command -v container', { stdio: 'ignore' });
containerRuntime = 'apple-container';
} catch {
try {
execSync('docker info', { stdio: 'ignore' });
containerRuntime = 'docker';
} catch {
// No runtime
}
}
// 3. Check credentials
let credentials = 'missing';
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8');
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
credentials = 'configured';
}
}
// 4. Check WhatsApp auth
let whatsappAuth = 'not_found';
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
whatsappAuth = 'authenticated';
}
// 5. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
registeredGroups = row.count;
db.close();
} catch {
// Table might not exist
}
}
// 6. Check mount allowlist
let mountAllowlist = 'missing';
if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) {
mountAllowlist = 'configured';
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
whatsappAuth !== 'not_found' &&
registeredGroups > 0
? 'success'
: 'failed';
logger.info({ status }, 'Verification complete');
emitStatus('VERIFY', {
SERVICE: service,
CONTAINER_RUNTIME: containerRuntime,
CREDENTIALS: credentials,
WHATSAPP_AUTH: whatsappAuth,
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

310
src/setup/whatsapp-auth.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* Step: whatsapp-auth — Full WhatsApp auth flow with polling.
* Replaces 04-auth-whatsapp.sh
*/
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { logger } from '../logger.js';
import { openBrowser, isHeadless } from './platform.js';
import { emitStatus } from './status.js';
const QR_AUTH_TEMPLATE = `<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings \\u2192 Linked Devices \\u2192 Link a Device</div>
</div>
<script>
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired \\u2014 a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>`;
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>`;
function parseArgs(args: string[]): { method: string; phone: string } {
let method = '';
let phone = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; }
if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; }
}
return { method, phone };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readFileSafe(filePath: string): string {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
function getPhoneNumber(projectRoot: string): string {
try {
const creds = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
);
if (creds.me?.id) {
return creds.me.id.split(':')[0].split('@')[0];
}
} catch {
// Not available yet
}
return '';
}
function emitAuthStatus(
method: string,
authStatus: string,
status: string,
extra: Record<string, string> = {},
): void {
const fields: Record<string, string> = {
AUTH_METHOD: method,
AUTH_STATUS: authStatus,
...extra,
STATUS: status,
LOG: 'logs/setup.log',
};
emitStatus('AUTH_WHATSAPP', fields);
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { method, phone } = parseArgs(args);
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
if (!method) {
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
process.exit(4);
}
// qr-terminal is a manual flow
if (method === 'qr-terminal') {
emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot });
return;
}
if (method === 'pairing-code' && !phone) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
process.exit(4);
}
if (!['qr-browser', 'pairing-code'].includes(method)) {
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' });
process.exit(4);
}
// Clean stale state
logger.info({ method }, 'Starting WhatsApp auth');
try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true }); } catch { /* ok */ }
try { fs.unlinkSync(qrFile); } catch { /* ok */ }
try { fs.unlinkSync(statusFile); } catch { /* ok */ }
// Start auth process in background
const authArgs = method === 'pairing-code'
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
: ['src/whatsapp-auth.ts'];
const authProc = spawn('npx', ['tsx', ...authArgs], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const logFile = path.join(projectRoot, 'logs', 'setup.log');
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
authProc.stdout?.pipe(logStream);
authProc.stderr?.pipe(logStream);
// Cleanup on exit
const cleanup = () => {
try { authProc.kill(); } catch { /* ok */ }
};
process.on('exit', cleanup);
try {
if (method === 'qr-browser') {
await handleQrBrowser(projectRoot, statusFile, qrFile);
} else {
await handlePairingCode(projectRoot, statusFile, phone);
}
} finally {
cleanup();
process.removeListener('exit', cleanup);
}
}
async function handleQrBrowser(
projectRoot: string,
statusFile: string,
qrFile: string,
): Promise<void> {
// Poll for QR data (15s)
let qrReady = false;
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('qr-browser', 'already_authenticated', 'success');
return;
}
if (fs.existsSync(qrFile)) {
qrReady = true;
break;
}
await sleep(1000);
}
if (!qrReady) {
emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' });
process.exit(3);
}
// Generate QR SVG and HTML
const qrData = fs.readFileSync(qrFile, 'utf-8');
try {
const svg = execSync(
`node -e "const QR=require('qrcode');const data=${JSON.stringify(qrData)};QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`,
{ cwd: projectRoot, encoding: 'utf-8' },
);
const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg);
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
fs.writeFileSync(htmlPath, html);
// Open in browser (cross-platform)
if (!isHeadless()) {
const opened = openBrowser(htmlPath);
if (!opened) {
logger.warn('Could not open browser — display QR in terminal as fallback');
}
} else {
logger.info('Headless environment — QR HTML saved but browser not opened');
}
} catch (err) {
logger.error({ err }, 'Failed to generate QR HTML');
}
// Poll for completion (120s)
await pollAuthCompletion('qr-browser', statusFile, projectRoot);
}
async function handlePairingCode(
projectRoot: string,
statusFile: string,
phone: string,
): Promise<void> {
// Poll for pairing code (15s)
let pairingCode = '';
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('pairing-code', 'already_authenticated', 'success');
return;
}
if (statusContent.startsWith('pairing_code:')) {
pairingCode = statusContent.replace('pairing_code:', '');
break;
}
if (statusContent.startsWith('failed:')) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: statusContent.replace('failed:', ''),
});
process.exit(1);
}
await sleep(1000);
}
if (!pairingCode) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
process.exit(3);
}
// Emit pairing code immediately so the caller can display it to the user
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode });
// Poll for completion (120s)
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
}
async function pollAuthCompletion(
method: string,
statusFile: string,
projectRoot: string,
pairingCode?: string,
): Promise<void> {
const extra: Record<string, string> = {};
if (pairingCode) extra.PAIRING_CODE = pairingCode;
for (let i = 0; i < 60; i++) {
const content = readFileSafe(statusFile);
if (content === 'authenticated' || content === 'already_authenticated') {
// Write success page if qr-auth.html exists
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
if (fs.existsSync(htmlPath)) {
fs.writeFileSync(htmlPath, SUCCESS_HTML);
}
const phoneNumber = getPhoneNumber(projectRoot);
if (phoneNumber) extra.PHONE_NUMBER = phoneNumber;
emitAuthStatus(method, content, 'success', extra);
return;
}
if (content.startsWith('failed:')) {
const error = content.replace('failed:', '');
emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra });
process.exit(1);
}
await sleep(2000);
}
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra });
process.exit(3);
}