diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md index 810ae035a..6362fe668 100644 --- a/.claude/skills/migrate-from-v1/SKILL.md +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -1,55 +1,69 @@ --- name: migrate-from-v1 -description: Finish migrating a NanoClaw v1 install into v2. Run this after `bash nanoclaw.sh` has completed its automated migration step. Seeds the owner user, applies v1 access defaults, fixes any migration sub-step that didn't finish, and interviews the user about custom v1 code to port forward. Triggers on "migrate from v1", "finish migration", "v1 migration", or automatically after setup when `logs/setup-migration/handoff.json` exists. +description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration". --- -# Migrate from v1 to v2 +# Finish v1 → v2 migration -> ⚠️ **Experimental.** This skill and the setup migration step are early. Remind the user to back up `data/v2.db` + `groups/` before making destructive changes, and prefer small, reversible edits. Not recommended yet for high-stakes production installs. +`bash migrate-v2.sh` already ran the deterministic migration. It handled: -The setup flow's `migration` step (in `setup/migrate-v1.ts`) already ran a best-effort automated pass. Your job is to finish what it couldn't do automatically, then interview the user about any custom code they had in v1 and help port it forward. +- .env keys merged +- v2 DB seeded (agent_groups, messaging_groups, wiring) +- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md) +- Session data copied with conversation continuity +- Scheduled tasks ported +- Channel code installed +- Container skills copied +- Container image built -Read [docs/v1-to-v2-changes.md](../../../docs/v1-to-v2-changes.md) before doing anything — it's the vocabulary for where v1 things moved to in v2. +Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations. -## What the automation did +Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list. -The setup flow ran these sub-steps (each as its own progression-log entry): +## Phase 0: Triage failed steps -| Sub-step | What it did | -|----------|-------------| -| `migrate-detect` | Found v1 install on disk (scanned `~/nanoclaw`, `~/.nanoclaw`, `~/Code/nanoclaw`, etc., or `$NANOCLAW_V1_PATH`). | -| `migrate-validate` | Checked v1 DB has expected tables + required columns. | -| `migrate-db` | Seeded `agent_groups` + `messaging_groups` + `messaging_group_agents` from `registered_groups`. Mapped `trigger_pattern`/`requires_trigger` → `engage_mode`/`engage_pattern`. Did NOT seed `users`/`user_roles`. | -| `migrate-groups` | Copied v1 `groups//` to v2. v1 `CLAUDE.md` → v2 `CLAUDE.local.md`. v1 `container_config` JSON → `.v1-container-config.json` sidecar (don't silent-map to v2's `container.json`). | -| `migrate-env` | Merged v1 `.env` keys into v2 `.env` (never overwrote existing keys). | -| `migrate-channel-auth` | Copied non-env auth state per channel (Baileys keystore, matrix state, etc.) based on `CHANNEL_AUTH_REGISTRY` in `setup/migrate-v1/shared.ts`. | -| `migrate-channels` | Ran `setup/install-.sh` for each channel detected in `registered_groups`. | -| `migrate-tasks` | Ported active v1 `scheduled_tasks` into each session's `inbound.db` as `kind='task'` rows. Inactive tasks dumped to `inactive-tasks.json` for reference. | +Check `handoff.json` → `overall_status`. If `"success"`, skip to Phase 1. -## Artifacts to read first +If `"partial"`, walk `handoff.steps` — each has `status` and `log` (path to the raw log file). For each failed step: -- `logs/setup-migration/handoff.json` — **start here.** Structured summary of every sub-step: `status`, `fields`, `notes`, plus detected channels, group selection, and a top-level `followups` list. The top-level `overall_status` tells you at a glance what kind of session this is. -- `logs/setup.log` — the progression log. Each `migrate-*` sub-step has one entry with status, duration, and a pointer to its raw log. -- `logs/setup-steps/NN-migrate-*.log` — raw per-sub-step stdout+stderr. Read these when a step failed or you need to understand why. -- `logs/setup-migration/schema-mismatch.json` — only exists if `migrate-validate` rejected the v1 DB shape. Describes what was missing. -- `logs/setup-migration/inactive-tasks.json` — v1 scheduled tasks we didn't migrate (completed, stopped, or unmappable schedule types). +1. Read its log file at `handoff.step_logs_dir/.log`. +2. Explain what failed in one sentence. +3. Fix it if mechanical (re-run the step script, hand-write a DB insert, copy a missed file). The step scripts are at `setup/migrate-v2/.ts` and accept `` as the first argument. +4. Use `AskUserQuestion` when a judgment call is needed. -## Flow +Common failures: +- **1b-db failed**: JID couldn't be parsed. Ask the user for the channel type, insert `agent_groups` + `messaging_groups` manually. +- **1d-sessions failed**: v2 DB wasn't seeded yet. Re-run after fixing 1b. +- **1e-tasks failed**: session doesn't exist yet. Re-run after fixing 1d. +- **2c-install-\ failed**: `git fetch origin channels` may have failed (network). Try again, or ask the user to run manually. +- **3e-container-build failed**: Docker issue. Read the build log, suggest fixes. -### Phase A — always run: owner seeding + access policy +After resolving all failures, proceed to Phase 1. -The automation deliberately did not seed `users`, `user_roles`, or flip `messaging_groups.unknown_sender_policy`. v1 has no ground truth for who the owner is, and no single source for the "anyone can message / only known users" setting. Ask the user. +## Phase 1: Owner and access -1. Read `handoff.json` → `detected_channels` to know which channel(s) to address the user on. -2. Use `AskUserQuestion` to ask "Which handle on `` is yours?" with options pulled from context if you have any hints (e.g. recent v1 message senders), plus "Let me type it" and "Use a different channel." Build the user id as `:`. -3. Insert into v2 central DB (`data/v2.db`): - - `users(id, kind, display_name, created_at)` — use the channel_type as `kind`. - - `user_roles(user_id, role='owner', agent_group_id=NULL, granted_by=NULL, granted_at=now)`. -4. Ask "In v1, could anyone message your assistant, or only known users?" via `AskUserQuestion`: - - "Anyone could message it" → update every row in `messaging_groups` (for migrated channel_types) to `unknown_sender_policy='public'`. - - "Only known users" → leave `unknown_sender_policy='strict'`; walk the user through seeding `agent_group_members` rows for each trusted handle they name. +v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted. -Use the DB helpers in `src/db/agent-groups.ts`, `src/db/messaging-groups.ts`, and `src/db/user-roles.ts` rather than hand-rolling SQL — they keep the companion `agent_destinations` and indexes correct. Always init the central DB first: +**User ID format**: always `:`. Each channel populates this differently: +- **Telegram**: `telegram:` (e.g. `telegram:6037840640`) +- **Discord**: `discord:` (e.g. `discord:123456789012345678`) +- **WhatsApp**: `whatsapp:@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`) +- **Slack**: `slack:` (e.g. `slack:U04ABCDEF`) +- **Others**: `:` + +**Steps:** + +1. Query `users` table: `SELECT id, kind, display_name FROM users`. +2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `` (``) you?" — Yes / No, let me type it. +3. If multiple users exist, present them as options in `AskUserQuestion`. +4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query. +5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert: + ```sql + INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('', 'owner', NULL, NULL, datetime('now')) + ``` + +Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first: ```ts import { initDb } from '../src/db/connection.js'; @@ -60,61 +74,144 @@ const db = initDb(path.join(DATA_DIR, 'v2.db')); runMigrations(db); ``` -### Phase B — branch on `handoff.json: overall_status` +### Access policy -**If `overall_status === 'success'`** and `followups` is empty: go straight to Phase C (customization interview). +After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it. -**Otherwise (partial, failed, or non-empty followups)**: walk `handoff.steps` and `handoff.followups` top-to-bottom. For each entry: +Present the options via `AskUserQuestion`: -- Read the step's `fields` and `notes` and its raw log (`logs/setup-steps/NN-.log`). -- Explain the situation to the user in one sentence, then propose a fix. -- Do the fix yourself when it's mechanical (re-running an install script, seeding a missed `agent_destinations` row, re-copying a channel's auth files, manually translating an unsupported `schedule_type`). Use `AskUserQuestion` when a judgment call is needed (is this orphan channel worth keeping? is this v1 container_config still relevant?). +1. **Public** (current) — anyone can message the bot. Good for personal DM bots. +2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped. +3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members. -Common cases: +If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group: -- **`migrate-validate` status=failed**: the v1 DB had an unexpected shape. Read `schema-mismatch.json`. If tables are missing, the user may have run a very old or customized v1 — ask before trying to salvage. If only columns are missing, you can often proceed by hand-writing the SELECT with the columns that exist. -- **`migrate-db` status=partial, SKIPPED>0**: some `registered_groups` rows didn't seed. The `notes` field of the step entry names each failed folder. Most commonly: a JID we couldn't parse. Ask the user whether to manually wire each. -- **`migrate-channels` status=partial, some entries `not_supported`**: v1 had channels v2 doesn't ship a skill for yet. Ask the user whether to keep the `messaging_groups` rows (they'll stay orphaned until v2 grows the adapter) or delete them. -- **`migrate-channel-auth` has `files_missing`**: for WhatsApp specifically, encryption sessions often can't survive the copy — tell the user a fresh pair may be needed via `/add-whatsapp`. -- **Per-folder `.v1-container-config.json` sidecars exist**: read each, discuss with the user, and translate to v2's `groups//container.json` format. +```sql +-- v1: unique senders per chat (excluding bot messages) +SELECT DISTINCT sender, sender_name +FROM messages +WHERE chat_jid = '' AND is_from_me = 0 AND sender IS NOT NULL +``` -### Phase C — customizations (fork-aware) +The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v1/shared.ts`) and combining: `:`. -NanoClaw recommends running on a fork, so most real v1 installs have at least some customizations. +For each sender: +1. Upsert into `users(id, kind, display_name)` if not already present. +2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group. -**Start with divergence detection.** In the v1 repo at `handoff.v1_path`: +Show the user the list of senders being imported and let them deselect any they don't want. + +Then update the messaging groups: +```sql +UPDATE messaging_groups SET unknown_sender_policy = '' +WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN ()) +``` + +## Phase 2: Clean up CLAUDE.local.md + +The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside. + +For each group that has a `CLAUDE.local.md`: + +1. Read the file. +2. Read the v1 template it was based on. Determine which template by checking the v1 install: + - If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md` + - Otherwise, the template was `groups/global/CLAUDE.md` + - The v1 path is in `handoff.json` → `v1_path` +3. Diff the file against the template. Identify sections that are: + - **Stock boilerplate** (identical to template) — remove. v2's fragments cover this. + - **User customizations** (added sections, modified sections) — keep. +4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified: + - "What You Can Do" → v2 runtime system prompt + - "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md` + - "Your Workspace" / workspace path references → `container/CLAUDE.md` + - "Memory" (the stock version) → `container/CLAUDE.md` + - "Message Formatting" → `container/CLAUDE.md` + - "Admin Context" → v2 uses `user_roles`, not is_main + - "Authentication" → v2 uses OneCLI + - "Container Mounts" → v2 mounts are different + - "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC + - "Global Memory" → v2 has `.claude-shared.md` symlink + - "Scheduling for Other Groups" → `module-scheduling.md` + - "Task Scripts" → `module-scheduling.md` + - "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles` +5. Fix path references in kept sections: + - `/workspace/group/` → `/workspace/agent/` + - `/workspace/project/` → these paths don't exist in v2; discuss with the user + - `/workspace/ipc/` → gone; remove references + - `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change +6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality. +7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original. + +If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading. + +## Phase 3: Container config + +`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar. + +For each group, check: + +1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist. +2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar. +3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable. + +## Phase 4: Fork customizations + +Check whether the user's v1 install was a customized fork. ```bash cd -git remote -v # identify the upstream remote -git log --oneline /main..HEAD # commits ahead of upstream +git remote -v +git log --oneline /main..HEAD 2>/dev/null ``` -If the log is **empty**: stock v1. Tell the user "no customizations to port" and skip the rest of Phase C. +If no commits ahead of upstream: stock v1, skip this phase. -If the log has commits, show them to the user and offer a scope via `AskUserQuestion`: +If there are commits: -1. **Mechanical** (recommended) — copy the portable categories (skills, docs), stash the rest as reference. -2. **Full interview** — walk each commit with me, decide one-by-one. Use `Explore` sub-agents for diffs > 10 files. -3. **Reference only** — stash everything to `docs/v1-fork-reference/`, copy nothing now. - -**Portability rules of thumb:** -- **Portable**: `container/skills/*`, `.claude/skills/*`, `docs/*`, top-level config. Scan each with `scanForV1Patterns` (in `setup/migrate-v1/shared.ts`) before copying — clean ones land as-is, dirty ones get a followup. -- **Not portable**: `src/*` (host) and `container/agent-runner/src/*` (agent-runner). v2's architecture is fundamentally different (Node host with split session DBs vs v1's single process + IPC file queue). Stash to `docs/v1-fork-reference/` with a README explaining the v1→v2 mapping — **don't translate**. Mechanical translation is a trap; let the user rebuild the feature on v2 primitives. -- **Already handled**: `groups/*` — `migrate-groups` copied these and flagged v1 patterns. Don't redo in Phase C. -- **Case by case**: `package.json` deps — check whether v2 already has each; never add to v2's lockfile without approval (supply-chain `minimumReleaseAge` applies). - -When stashing, write `docs/v1-fork-reference/README.md` with commits list, stashed source files, and the suggested porting plan. +1. Show the commit list to the user. +2. `AskUserQuestion`: "How do you want to handle your v1 customizations?" + - **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v1/shared.ts`. + - **Full walkthrough** — go commit by commit, decide together. + - **Reference only** — stash to `docs/v1-fork-reference/` for later. +3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate. ## Principles -- **Never silently copy code.** Read, explain, propose, apply. Show diffs before applying when non-trivial. -- **Credentials are masked when displayed** (first 4 + `...` + last 4 characters). The handoff file doesn't contain values; keep it that way. -- **The v1 checkout is read-only.** We never delete or modify `~/nanoclaw`. If the user wants to retire it later, that's a separate conversation. -- **No migration re-runs.** The `migrate-*` sub-steps are idempotent, but re-running them from inside this skill is almost always the wrong move — finish by hand. Re-running is for when the user re-runs `bash nanoclaw.sh`. -- **`handoff.json` is source of truth across context compactions.** If the conversation gets compacted mid-work, re-read it and `git status` to recover state. Do not maintain a separate state file. +- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`. +- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json. +- **Mask credentials** when displaying (first 4 + `...` + last 4 characters). +- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state. -## When you're done +## Setup steps you can run -- Delete `logs/setup-migration/handoff.json` once every followup is cleared and the user confirms. The file is a working document, not a record — if the user wants a record, offer to move it to `docs/migration-.md` before deleting. -- Tell the user: if the service is running (check `launchctl list | grep nanoclaw` on macOS or `systemctl --user status nanoclaw*` on Linux), restart it so the seeded `users` / `user_roles` / any channel installs take effect. +The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed: + +```bash +pnpm exec tsx setup/index.ts --step +``` + +| Step | When to use | +|------|-------------| +| `onecli` | OneCLI not installed or not healthy | +| `auth` | No Anthropic credential in vault | +| `container` | Container image needs rebuild | +| `service` | Service not installed or not running | +| `mounts` | Mount allowlist missing | +| `verify` | End-to-end health check (run after everything else) | +| `environment` | System check (Node, dirs) | + +## When done + +1. Run the verify step to confirm everything works: + ```bash + pnpm exec tsx setup/index.ts --step verify + ``` +2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-.md` first. +3. Restart the service if running so changes take effect: + ```bash + # Linux + systemctl --user restart nanoclaw-v2-* + # macOS + launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-* + ``` diff --git a/docs/migration-dev.md b/docs/migration-dev.md new file mode 100644 index 000000000..60feb4ef0 --- /dev/null +++ b/docs/migration-dev.md @@ -0,0 +1,139 @@ +# v1 → v2 Migration — Development Guide + +How to test, develop, and debug the migration flow. + +## Quick start + +```bash +# Full cycle: reset → migrate → Claude finishes +bash migrate-v2-reset.sh && bash migrate-v2.sh +``` + +## Architecture + +Two-part migration: + +1. **`migrate-v2.sh`** — deterministic bash script. Handles prerequisites, DB seeding, file copies, channel install, container build, service switchover. Writes `logs/setup-migration/handoff.json` then `exec`s into Claude. + +2. **`/migrate-from-v1` skill** — Claude-driven. Reads the handoff, seeds owner/roles, cleans up CLAUDE.local.md, validates container configs, ports fork customizations. + +## File layout + +``` +migrate-v2.sh # Entry point +migrate-v2-reset.sh # Wipe v2 state for re-testing +setup/migrate-v2/ + env.ts # Phase 1a: merge .env + db.ts # Phase 1b: seed v2 DB + groups.ts # Phase 1c: copy group folders + container.json + sessions.ts # Phase 1d: copy sessions + set continuation + tasks.ts # Phase 1e: port scheduled tasks + channel-auth.ts # Phase 2b: copy channel auth state + select-channels.ts # Phase 2a: clack multiselect + switchover-prompt.ts # Service switch prompts +setup/migrate-v1/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.) +.claude/skills/migrate-from-v1/ # The Claude skill +logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill +logs/migrate-steps/*.log # Per-step raw output +``` + +## Development loop + +```bash +# Reset v2 to clean state (keeps node_modules) +bash migrate-v2-reset.sh + +# Run migration with non-interactive channel selection +NANOCLAW_CHANNELS="telegram" bash migrate-v2.sh + +# Or run interactively (clack multiselect) +bash migrate-v2.sh +``` + +`migrate-v2-reset.sh` wipes: `data/`, `logs/`, `.env`, `groups/` (restores git-tracked), `container/skills/` (restores git-tracked), `src/channels/` (restores git-tracked). + +It does NOT wipe `node_modules/` (expensive to reinstall). + +## Testing individual steps + +Each step is a standalone TypeScript file: + +```bash +# Run a single step (after pnpm install) +pnpm exec tsx setup/migrate-v2/env.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/db.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/groups.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/sessions.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/tasks.ts /path/to/v1 +pnpm exec tsx setup/migrate-v2/channel-auth.ts /path/to/v1 telegram discord +``` + +Each prints `OK:
`, `SKIPPED:`, or errors to stdout. Exit 0 on success/skip, non-zero on failure. + +## Debugging + +### Check what was migrated + +```bash +# Agent groups +sqlite3 data/v2.db "SELECT * FROM agent_groups" + +# Messaging groups + wiring +sqlite3 data/v2.db "SELECT mg.id, mg.channel_type, mg.platform_id, mg.unknown_sender_policy, mga.engage_mode, mga.engage_pattern FROM messaging_groups mg JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id" + +# Sessions +sqlite3 data/v2.db "SELECT * FROM sessions" + +# Users and roles +sqlite3 data/v2.db "SELECT * FROM users" +sqlite3 data/v2.db "SELECT * FROM user_roles" + +# Session continuation (which Claude Code session will be resumed) +AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups LIMIT 1") +SESS_ID=$(sqlite3 data/v2.db "SELECT id FROM sessions LIMIT 1") +sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/outbound.db "SELECT * FROM session_state" + +# Scheduled tasks +sqlite3 data/v2-sessions/$AG_ID/$SESS_ID/inbound.db "SELECT id, kind, recurrence, status FROM messages_in WHERE kind='task'" +``` + +### Check handoff + +```bash +python3 -m json.tool logs/setup-migration/handoff.json +``` + +### Common issues + +**Bot doesn't respond after switchover:** +1. Check both services aren't running: `systemctl --user list-units 'nanoclaw*'` +2. Check error log: `tail logs/nanoclaw.error.log` +3. Check sender policy: `sqlite3 data/v2.db "SELECT unknown_sender_policy FROM messaging_groups"` — must be `public` before owner is seeded +4. Check engage pattern: `sqlite3 data/v2.db "SELECT engage_mode, engage_pattern FROM messaging_group_agents"` — should be `pattern` / `.` for respond-to-everything + +**Session not continuing from v1:** +1. Check continuation is set: see "Session continuation" query above +2. Check JSONL exists at the right path: `ls data/v2-sessions//.claude-shared/projects/-workspace-agent/` +3. The v1 session JSONL should be copied from `-workspace-group/` to `-workspace-agent/` (v2 container CWD is `/workspace/agent`) + +**Service switchover revert didn't work:** +1. The v2 service name is `nanoclaw-v2-` — find it: `systemctl --user list-units 'nanoclaw*'` +2. Manually stop: `systemctl --user stop && systemctl --user disable ` +3. Restart v1: `systemctl --user start nanoclaw` + +### Step logs + +Each step writes raw output to `logs/migrate-steps/.log`. Read these when a step fails: + +```bash +cat logs/migrate-steps/1b-db.log +cat logs/migrate-steps/1d-sessions.log +``` + +## Key decisions + +- `unknown_sender_policy` is set to `public` during migration so the bot responds immediately. The `/migrate-from-v1` skill tightens it after seeding the owner. +- `requires_trigger=0` in v1 takes priority over a non-empty `trigger_pattern` — it means "respond to everything." +- v1 `container_config.additionalMounts` is written directly to v2 `container.json` (same shape). +- v1 Claude Code sessions are copied from `-workspace-group/` to `-workspace-agent/` and the session ID is written to `outbound.db` as `continuation:claude` so the agent-runner resumes the same conversation. +- `exec claude "/migrate-from-v1"` at the end replaces the bash process — `write_handoff` is called explicitly before `exec` since EXIT traps don't fire on `exec`. diff --git a/migrate-v2-reset.sh b/migrate-v2-reset.sh new file mode 100644 index 000000000..b795745e9 --- /dev/null +++ b/migrate-v2-reset.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# migrate-v2-reset.sh — Wipe v2 migration state back to clean. +# +# For development iteration: +# bash migrate-v2-reset.sh && bash migrate-v2.sh +# +# What it removes: +# - data/ (v2 DBs, session state) +# - logs/ (migration + setup logs) +# - .env (merged env keys) +# - groups/*/ (non-git group folders copied from v1) +# +# What it restores: +# - groups/global/CLAUDE.md and groups/main/CLAUDE.md from git +# +# What it does NOT touch: +# - node_modules/ (expensive to reinstall, keep it) +# - The v1 install (read-only, never modified) + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; } + +clean() { + local target=$1 label=$2 + if [ -e "$target" ]; then + rm -rf "$target" + printf '%s Removed %s\n' "$(green '✓')" "$label" + fi +} + +echo +printf '%s\n\n' "$(dim 'Resetting v2 migration state…')" + +clean "data" "data/" +clean "logs" "logs/" +clean ".env" ".env" + +# Remove all group folders, then restore the two git-tracked ones +if [ -d "groups" ]; then + rm -rf groups + printf '%s Removed %s\n' "$(green '✓')" "groups/" +fi +git checkout -- groups/ 2>/dev/null || true +printf '%s Restored %s\n' "$(green '✓')" "groups/ from git" + +# Restore container/skills/ to git state (remove v1-copied skills) +git checkout -- container/skills/ 2>/dev/null || true +# Remove any untracked skill dirs that were copied from v1 +for d in container/skills/*/; do + [ -d "$d" ] || continue + if ! git ls-files --error-unmatch "$d" >/dev/null 2>&1; then + rm -rf "$d" + fi +done +printf '%s Restored %s\n' "$(green '✓')" "container/skills/ from git" + +# Restore channel code (src/channels/) to git state +git checkout -- src/channels/ 2>/dev/null || true +printf '%s Restored %s\n' "$(green '✓')" "src/channels/ from git" + +echo +printf '%s\n\n' "$(dim 'Clean. Run: bash migrate-v2.sh')" diff --git a/migrate-v2.sh b/migrate-v2.sh new file mode 100644 index 000000000..325b4918e --- /dev/null +++ b/migrate-v2.sh @@ -0,0 +1,641 @@ +#!/usr/bin/env bash +# +# migrate-v2.sh — Migrate a NanoClaw v1 install into this v2 checkout. +# +# Run from the v2 directory: +# bash migrate-v2.sh +# +# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). +# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh +# bootstrap, then runs the migration steps. +# +# Idempotent — safe to re-run. Use migrate-v2-reset.sh to wipe v2 state +# back to clean for development iteration. + +set -uo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +LOGS_DIR="$PROJECT_ROOT/logs" +STEPS_DIR="$LOGS_DIR/migrate-steps" +MIGRATE_LOG="$LOGS_DIR/migrate-v2.log" + +# Defaults for variables that may not be set if we exit early +V1_PATH="" +V1_VERSION="unknown" +ONECLI_OK=false +SERVICE_SWITCHED=false +SELECTED_CHANNELS=() +ABORTED_AT="" + +# Write handoff.json on any exit so the skill can always read it +write_handoff() { + local handoff_dir="$LOGS_DIR/setup-migration" + mkdir -p "$handoff_dir" + + local has_failures=false + for step_name in "${!STEP_RESULTS[@]}"; do + [ "${STEP_RESULTS[$step_name]}" = "failed" ] && has_failures=true + done + + local overall="success" + $has_failures && overall="partial" + [ -n "$ABORTED_AT" ] && overall="failed" + + local steps_json="{" + for step_name in "${!STEP_RESULTS[@]}"; do + steps_json="${steps_json}\"${step_name}\": {\"status\": \"${STEP_RESULTS[$step_name]}\", \"log\": \"logs/migrate-steps/${step_name}.log\"}," + done + steps_json="${steps_json%,}}" + + cat > "$handoff_dir/handoff.json" </dev/null | sed 's/,$//')], + "onecli_healthy": $ONECLI_OK, + "service_switched": $SERVICE_SWITCHED, + "steps": $steps_json, + "step_logs_dir": "logs/migrate-steps", + "followups": [ + "Seed owner user and access policy", + "Review CLAUDE.local.md files for v1-specific patterns", + "Verify container.json mount paths are valid" + ] +} +HANDOFF_EOF +} + +trap write_handoff EXIT + +abort() { + ABORTED_AT="$1" + log "ABORTED at $1" + exit 1 +} + +# ─── output helpers ────────────────────────────────────────────────────── + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; } +red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; } +clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } + +step_ok() { printf '%s %s\n' "$(green '✓')" "$1"; } +step_fail() { printf '%s %s\n' "$(red '✗')" "$1"; } +step_skip() { printf '%s %s\n' "$(dim '–')" "$1"; } +step_info() { printf '%s %s\n' "$(dim '·')" "$1"; } + +ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$MIGRATE_LOG" +} + +# ─── init logs ─────────────────────────────────────────────────────────── + +mkdir -p "$STEPS_DIR" +{ + echo "## $(ts_utc) · migrate-v2.sh started" + echo " cwd: $PROJECT_ROOT" + echo "" +} > "$MIGRATE_LOG" + +echo +bold "NanoClaw v1 → v2 migration" +echo +echo + +# ─── phase 0a: bootstrap prerequisites ────────────────────────────────── + +step_info "Installing prerequisites (Node, pnpm, dependencies)…" + +BOOTSTRAP_RAW="$STEPS_DIR/01-bootstrap.log" +export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW" + +if bash "$PROJECT_ROOT/setup.sh" > "$BOOTSTRAP_RAW" 2>&1; then + # Parse the status block from setup.sh output + STATUS=$(grep '^STATUS:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^STATUS: *//') + NODE_VERSION=$(grep '^NODE_VERSION:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^NODE_VERSION: *//') + + if [ "$STATUS" = "success" ]; then + step_ok "Prerequisites ready $(dim "(node $NODE_VERSION)")" + log "Bootstrap succeeded: node=$NODE_VERSION" + else + step_fail "Bootstrap reported: $STATUS" + echo + dim " See: $BOOTSTRAP_RAW" + echo + abort "bootstrap" + fi +else + step_fail "Bootstrap failed" + echo + echo "$(dim '── last 20 lines ──')" + tail -20 "$BOOTSTRAP_RAW" 2>/dev/null || true + echo + dim " Full log: $BOOTSTRAP_RAW" + echo + abort "bootstrap" +fi + +# setup.sh may have installed pnpm to a prefix not on our PATH — replay +# the same lookup nanoclaw.sh does. +if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + NPM_PREFIX="$(npm config get prefix 2>/dev/null)" + if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then + export PATH="$NPM_PREFIX/bin:$PATH" + fi +fi + +if ! command -v pnpm >/dev/null 2>&1; then + step_fail "pnpm not found after bootstrap" + abort "pnpm-missing" +fi + +# ─── phase 0b: find v1 install ────────────────────────────────────────── + +find_v1() { + # Explicit override + if [ -n "${NANOCLAW_V1_PATH:-}" ]; then + if [ -f "$NANOCLAW_V1_PATH/store/messages.db" ]; then + echo "$NANOCLAW_V1_PATH" + return 0 + fi + step_fail "NANOCLAW_V1_PATH=$NANOCLAW_V1_PATH does not contain store/messages.db" + return 1 + fi + + # Scan sibling directories for anything claw-ish with a v1 DB + local parent + parent="$(dirname "$PROJECT_ROOT")" + for entry in "$parent"/*/; do + [ -d "$entry" ] || continue + # Skip ourselves + [ "$(cd "$entry" && pwd)" = "$PROJECT_ROOT" ] && continue + # Must have the v1 DB + [ -f "$entry/store/messages.db" ] || continue + # Must not be v2 (check package.json version) + if [ -f "$entry/package.json" ]; then + local ver + ver=$(grep '"version"' "$entry/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([0-9]+)\..*/\1/') + [ "$ver" = "2" ] && continue + fi + echo "$(cd "$entry" && pwd)" + return 0 + done + + return 1 +} + +V1_PATH="" +if V1_PATH=$(find_v1); then + V1_VERSION=$(grep '"version"' "$V1_PATH/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "unknown") + step_ok "Found v1 at $(dim "$V1_PATH") $(dim "(v$V1_VERSION)")" + log "v1 found: $V1_PATH (v$V1_VERSION)" +else + step_fail "No v1 install found" + echo + echo " $(dim 'Set NANOCLAW_V1_PATH to point at your v1 checkout:')" + echo " $(dim 'NANOCLAW_V1_PATH=~/nanoclaw bash migrate-v2.sh')" + echo + abort "v1-not-found" +fi + +# ─── phase 0c: validate v1 DB ─────────────────────────────────────────── + +V1_DB="$V1_PATH/store/messages.db" + +# Quick schema check — make sure the tables we need exist +TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true) + +if echo "$TABLES" | grep -q "registered_groups"; then + step_ok "v1 database has registered_groups" +else + step_fail "v1 database missing registered_groups table" + abort "v1-db-invalid" +fi + +# Show what we found +GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) +TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) +ENV_KEYS=0 +if [ -f "$V1_PATH/.env" ]; then + ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0) +fi + +step_info "v1 state: $(bold "$GROUP_COUNT") groups, $(bold "$TASK_COUNT") active tasks, $(bold "$ENV_KEYS") env keys" + +echo +step_ok "Phase 0 complete — ready to migrate" +echo +log "Phase 0 complete: groups=$GROUP_COUNT tasks=$TASK_COUNT env_keys=$ENV_KEYS" + +export NANOCLAW_V1_PATH="$V1_PATH" +export NANOCLAW_V2_PATH="$PROJECT_ROOT" + +# ─── run_step helper ───────────────────────────────────────────────────── +# Runs a TypeScript migration step, captures output, reports success/failure. + +# Track step outcomes for handoff.json +declare -A STEP_RESULTS + +run_step() { + local name=$1 label=$2 script=$3 + shift 3 + local raw="$STEPS_DIR/${name}.log" + + if pnpm exec tsx "$script" "$@" > "$raw" 2>&1; then + local result + result=$(grep '^OK:' "$raw" | head -1 || true) + step_ok "$label $(dim "$result")" + log "$name: $result" + STEP_RESULTS[$name]="success" + elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then + local reason + reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://') + step_skip "$label $(dim "($reason)")" + log "$name: skipped ($reason)" + STEP_RESULTS[$name]="skipped" + else + step_fail "$label" + echo + tail -10 "$raw" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + echo + log "$name: FAILED (see $raw)" + STEP_RESULTS[$name]="failed" + fi +} + +# ─── phase 1: core state ──────────────────────────────────────────────── + +echo "$(bold 'Phase 1: Core state')" +echo + +run_step "1a-env" \ + "Merge .env" \ + "setup/migrate-v2/env.ts" "$V1_PATH" + +run_step "1b-db" \ + "Seed v2 database" \ + "setup/migrate-v2/db.ts" "$V1_PATH" + +run_step "1c-groups" \ + "Copy group folders" \ + "setup/migrate-v2/groups.ts" "$V1_PATH" + +run_step "1d-sessions" \ + "Copy session data" \ + "setup/migrate-v2/sessions.ts" "$V1_PATH" + +run_step "1e-tasks" \ + "Port scheduled tasks" \ + "setup/migrate-v2/tasks.ts" "$V1_PATH" + +echo +step_ok "Phase 1 complete" +echo + +# ─── phase 2: channels (interactive) ──────────────────────────────────── + +echo "$(bold 'Phase 2: Channels')" +echo + +# Channel selection — clack multiselect (interactive) or NANOCLAW_CHANNELS env var. +# NANOCLAW_CHANNELS accepts comma-separated channel names: "telegram,discord" +SELECTED_CHANNELS=() +CHANNEL_SELECT_OUT="$STEPS_DIR/2a-channels-selected.txt" + +pnpm exec tsx setup/migrate-v2/select-channels.ts "$CHANNEL_SELECT_OUT" || true + +if [ -f "$CHANNEL_SELECT_OUT" ]; then + while IFS= read -r ch; do + [ -n "$ch" ] && SELECTED_CHANNELS+=("$ch") + done < "$CHANNEL_SELECT_OUT" +fi + +if [ ${#SELECTED_CHANNELS[@]} -eq 0 ]; then + echo + step_skip "No channels selected" +else + echo + step_info "Selected: ${SELECTED_CHANNELS[*]}" + echo + + # 2b. Copy channel auth state + run_step "2b-channel-auth" \ + "Copy channel credentials" \ + "setup/migrate-v2/channel-auth.ts" "$V1_PATH" "${SELECTED_CHANNELS[@]}" + + # 2c. Install channel code + for ch in "${SELECTED_CHANNELS[@]}"; do + INSTALL_SCRIPT="setup/install-${ch}.sh" + if [ -f "$INSTALL_SCRIPT" ]; then + STEP_LOG="$STEPS_DIR/2c-install-${ch}.log" + if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then + STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//') + if [ "$STATUS_LINE" = "already-installed" ]; then + step_skip "Install $ch $(dim "(already installed)")" + else + step_ok "Install $ch" + fi + log "install-$ch: $STATUS_LINE" + else + step_fail "Install $ch" + tail -5 "$STEP_LOG" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "install-$ch: FAILED (see $STEP_LOG)" + fi + else + step_skip "Install $ch $(dim "(no install script)")" + fi + done +fi + +echo +step_ok "Phase 2 complete" +echo + +# ─── phase 3: infrastructure ──────────────────────────────────────────── + +echo "$(bold 'Phase 3: Infrastructure')" +echo + +# 3a. OneCLI — detect or install via setup step +ONECLI_OK=false +ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') +ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" + +if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then + step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" + ONECLI_OK=true + log "OneCLI: running at $ONECLI_URL_CHECK" +else + # Run the setup onecli step — it handles install, reuse, and health checks + step_info "Setting up OneCLI…" + ONECLI_LOG="$STEPS_DIR/3a-onecli.log" + ONECLI_ERR="$STEPS_DIR/3a-onecli.err" + if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then + step_ok "OneCLI ready" + ONECLI_OK=true + STEP_RESULTS["3a-onecli"]="success" + log "OneCLI: installed/configured" + else + step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")" + STEP_RESULTS["3a-onecli"]="failed" + log "OneCLI: FAILED" + fi +fi + +# 3b. Anthropic credential — run the auth setup step if no credential found +if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then + step_ok "Anthropic credential found in .env" + log "Anthropic credential: found in .env" +elif [ "$ONECLI_OK" = "true" ]; then + step_info "Registering Anthropic credential…" + AUTH_LOG="$STEPS_DIR/3b-auth.log" + AUTH_ERR="$STEPS_DIR/3b-auth.err" + if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then + step_ok "Anthropic credential registered" + STEP_RESULTS["3b-auth"]="success" + log "Anthropic credential: registered via auth step" + else + step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")" + STEP_RESULTS["3b-auth"]="failed" + log "Anthropic credential: FAILED" + fi +else + step_info "No Anthropic credential $(dim "(OneCLI not available — add manually to .env)")" + log "Anthropic credential: skipped (no OneCLI)" +fi + +# 3c. Docker check +if command -v docker >/dev/null 2>&1; then + DOCKER_V=$(docker --version 2>/dev/null | head -1) + step_ok "Docker available $(dim "($DOCKER_V)")" + log "Docker: $DOCKER_V" +else + step_fail "Docker not found" + step_info "$(dim "Install Docker: bash setup/install-docker.sh")" + log "Docker: not found" +fi + +# 3d. Copy container skills from v1 that v2 doesn't have +V1_SKILLS_DIR="$V1_PATH/container/skills" +V2_SKILLS_DIR="$PROJECT_ROOT/container/skills" + +if [ -d "$V1_SKILLS_DIR" ]; then + SKILLS_COPIED=0 + SKILLS_SKIPPED=0 + for skill_dir in "$V1_SKILLS_DIR"/*/; do + [ -d "$skill_dir" ] || continue + skill_name=$(basename "$skill_dir") + if [ -d "$V2_SKILLS_DIR/$skill_name" ]; then + SKILLS_SKIPPED=$((SKILLS_SKIPPED + 1)) + else + cp -r "$skill_dir" "$V2_SKILLS_DIR/$skill_name" + SKILLS_COPIED=$((SKILLS_COPIED + 1)) + fi + done + if [ $SKILLS_COPIED -gt 0 ]; then + step_ok "Copied $SKILLS_COPIED container skills $(dim "(skipped $SKILLS_SKIPPED already in v2)")" + else + step_skip "All v1 container skills already in v2 $(dim "($SKILLS_SKIPPED)")" + fi + log "Container skills: copied=$SKILLS_COPIED skipped=$SKILLS_SKIPPED" +else + step_skip "No v1 container skills" +fi + +# 3e. Build agent container image +if command -v docker >/dev/null 2>&1; then + step_info "Building agent container image…" + BUILD_LOG="$STEPS_DIR/3e-container-build.log" + if bash container/build.sh > "$BUILD_LOG" 2>&1; then + step_ok "Container image built" + log "Container build: success" + else + step_fail "Container build failed" + tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do + echo " $(dim "$line")" + done + log "Container build: FAILED (see $BUILD_LOG)" + fi +else + step_skip "Container build $(dim "(no Docker)")" +fi + +echo +step_ok "Phase 3 complete" +echo + +# ─── service switchover ───────────────────────────────────────────────── + +echo "$(bold 'Service switchover')" +echo + +# Detect platform and service names +V1_SERVICE="" +V2_SERVICE="" +PLATFORM_SERVICE="" + +if [ "$(uname -s)" = "Darwin" ]; then + PLATFORM_SERVICE="launchd" + V1_SERVICE="com.nanoclaw" + # v2 uses install-slug for unique service names + V2_SERVICE=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())" 2>/dev/null || echo "") +elif [ "$(uname -s)" = "Linux" ]; then + PLATFORM_SERVICE="systemd" + V1_SERVICE="nanoclaw" + V2_SERVICE=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())" 2>/dev/null || echo "") +fi + +# Check if v1 service is running +V1_RUNNING=false +if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user is-active "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true +elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl list "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true +fi + +SERVICE_SWITCHED=false +if [ "$V1_RUNNING" = "true" ]; then + step_info "v1 service is running $(dim "($V1_SERVICE)")" + + # Ask user if they want to switch + SWITCH_ANSWER_FILE=$(mktemp) + pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch "$SWITCH_ANSWER_FILE" || true + SWITCH_ANSWER=$(cat "$SWITCH_ANSWER_FILE" 2>/dev/null || echo "skip") + rm -f "$SWITCH_ANSWER_FILE" + + if [ "$SWITCH_ANSWER" = "switch" ]; then + # Stop v1 + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user stop "$V1_SERVICE" 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1" + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl unload ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1" + fi + + # Install and start v2 service + V2_SERVICE_LOG="$STEPS_DIR/service-install.log" + V2_SERVICE_ERR="$STEPS_DIR/service-install.err" + if pnpm exec tsx setup/index.ts --step service > "$V2_SERVICE_LOG" 2>"$V2_SERVICE_ERR"; then + # Parse the actual unit name from the service step stdout (clean, no ANSI) + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + V2_SERVICE=$(grep '^SERVICE_UNIT:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_UNIT: *//') + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + V2_SERVICE=$(grep '^SERVICE_LABEL:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_LABEL: *//') + fi + step_ok "v2 service installed and started $(dim "($V2_SERVICE)")" + else + step_fail "Could not start v2 service $(dim "(see $V2_SERVICE_LOG)")" + fi + + SERVICE_SWITCHED=true + echo + step_info "v2 is running — send a test message to your bot" + echo + + # Ask: keep or revert? + KEEP_ANSWER_FILE=$(mktemp) + pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --keep-or-revert "$KEEP_ANSWER_FILE" || true + KEEP_ANSWER=$(cat "$KEEP_ANSWER_FILE" 2>/dev/null || echo "keep") + rm -f "$KEEP_ANSWER_FILE" + + if [ "$KEEP_ANSWER" = "revert" ]; then + # Stop v2 + if [ "$PLATFORM_SERVICE" = "systemd" ] && [ -n "$V2_SERVICE" ]; then + systemctl --user stop "$V2_SERVICE" 2>/dev/null || true + systemctl --user disable "$V2_SERVICE" 2>/dev/null || true + elif [ "$PLATFORM_SERVICE" = "launchd" ] && [ -n "$V2_SERVICE" ]; then + launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist 2>/dev/null || true + fi + + # Restart v1 + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user start "$V1_SERVICE" 2>/dev/null || true + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null || true + fi + + step_ok "Reverted to v1 service" + SERVICE_SWITCHED=false + else + step_ok "Keeping v2 service" + # Disable v1 from auto-starting + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + systemctl --user disable "$V1_SERVICE" 2>/dev/null || true + fi + fi + else + step_skip "Service switchover skipped" + fi +else + step_skip "v1 service not running — nothing to switch" +fi + +echo + +# ─── phase 4: handoff ─────────────────────────────────────────────────── +# handoff.json is written by the EXIT trap (write_handoff) — always, even on +# abort. Here we just print the summary. + +echo "$(bold 'Phase 4: Handoff')" +echo + +step_ok "Wrote handoff summary" + +# Summary +echo +echo "$(bold '── Migration complete ──')" +echo +echo " $(dim 'v1:') $V1_PATH" +echo " $(dim 'v2:') $PROJECT_ROOT" +echo +echo " $(bold 'What was done:')" +echo " $(green '✓') .env keys merged" +echo " $(green '✓') Database seeded (agent groups, messaging groups, wiring)" +echo " $(green '✓') Group folders copied (CLAUDE.md → CLAUDE.local.md)" +echo " $(green '✓') Session data copied" +echo " $(green '✓') Scheduled tasks ported" +if [ ${#SELECTED_CHANNELS[@]} -gt 0 ]; then +echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}" +fi +echo " $(green '✓') Container skills copied" +echo " $(green '✓') Container image built" +echo +echo " $(bold 'What still needs a human:')" +if [ "$ONECLI_OK" = "false" ]; then +echo " $(dim '·') Set up OneCLI: pnpm exec tsx setup/index.ts --step onecli" +fi +if ! grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then +echo " $(dim '·') Add Anthropic credential to .env or OneCLI vault" +fi +echo " $(dim '·') Run $(bold '/migrate-from-v1') in Claude to finish:" +echo " $(dim '- Seed your owner account')" +echo " $(dim '- Set access policies')" +echo " $(dim '- Port any custom v1 code')" +echo +echo " $(dim "Handoff: $LOGS_DIR/setup-migration/handoff.json")" +echo " $(dim "Full log: $MIGRATE_LOG")" +echo " $(dim "Step logs: $STEPS_DIR/")" +echo + +# ─── hand off to Claude ───────────────────────────────────────────────── + +if command -v claude >/dev/null 2>&1; then + write_handoff + trap - EXIT + exec claude "/migrate-from-v1" +fi diff --git a/setup/migrate-v1/shared.ts b/setup/migrate-v1/shared.ts index 4597fcf54..4d2cd92f1 100644 --- a/setup/migrate-v1/shared.ts +++ b/setup/migrate-v1/shared.ts @@ -431,12 +431,14 @@ export function triggerToEngage(input: { if (pattern === '.' || pattern === '.*') { return { engage_mode: 'pattern', engage_pattern: '.' }; } - if (pattern) { - return { engage_mode: 'pattern', engage_pattern: pattern }; - } + // requires_trigger=0 means "respond to everything" regardless of pattern. + // The pattern was used for mention highlighting, not message gating. if (!requiresTrigger) { return { engage_mode: 'pattern', engage_pattern: '.' }; } + if (pattern) { + return { engage_mode: 'pattern', engage_pattern: pattern }; + } return { engage_mode: 'mention', engage_pattern: null }; } diff --git a/setup/migrate-v2/channel-auth.ts b/setup/migrate-v2/channel-auth.ts new file mode 100644 index 000000000..788ae9d69 --- /dev/null +++ b/setup/migrate-v2/channel-auth.ts @@ -0,0 +1,134 @@ +/** + * migrate-v2 step: channel-auth + * + * Copy channel auth state from v1 to v2 for selected channels. + * Handles both env keys and on-disk auth files (Baileys, Matrix, etc.) + * per the CHANNEL_AUTH_REGISTRY. + * + * Usage: pnpm exec tsx setup/migrate-v2/channel-auth.ts [channel2...] + */ +import fs from 'fs'; +import path from 'path'; + +import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js'; + +function parseEnv(filePath: string): Map { + const out = new Map(); + if (!fs.existsSync(filePath)) return out; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + out.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); + } + return out; +} + +function appendEnvKey(envPath: string, key: string, value: string): boolean { + const existing = parseEnv(envPath); + if (existing.has(key)) return false; + + let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : ''; + if (content && !content.endsWith('\n')) content += '\n'; + content += `${key}=${value}\n`; + fs.writeFileSync(envPath, content); + return true; +} + +function copyGlob(v1Root: string, v2Root: string, relativePath: string): string[] { + const src = path.join(v1Root, relativePath); + if (!fs.existsSync(src)) return []; + + const copied: string[] = []; + const stat = fs.statSync(src); + + if (stat.isFile()) { + const dst = path.join(v2Root, relativePath); + if (!fs.existsSync(dst)) { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + copied.push(relativePath); + } + } else if (stat.isDirectory()) { + const dst = path.join(v2Root, relativePath); + fs.mkdirSync(dst, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const sub = path.join(relativePath, entry.name); + copied.push(...copyGlob(v1Root, v2Root, sub)); + } + } + + return copied; +} + +function main(): void { + const args = process.argv.slice(2); + const v1Path = args[0]; + const channels = args.slice(1); + + if (!v1Path || channels.length === 0) { + console.error('Usage: tsx setup/migrate-v2/channel-auth.ts [channel2...]'); + process.exit(1); + } + + const v1EnvPath = path.join(v1Path, '.env'); + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Env = parseEnv(v1EnvPath); + + let envKeysCopied = 0; + let filesCopied = 0; + let channelsProcessed = 0; + const missing: string[] = []; + + for (const channel of channels) { + const spec = CHANNEL_AUTH_REGISTRY[channel]; + if (!spec) { + // Unknown channel — just try copying env keys with common naming + channelsProcessed++; + continue; + } + + // Copy env keys + for (const key of spec.v1EnvKeys) { + const value = v1Env.get(key); + if (value) { + if (appendEnvKey(v2EnvPath, key, value)) { + envKeysCopied++; + } + } + } + + // Check required v2 keys — report missing ones + const v2Env = parseEnv(v2EnvPath); + for (const req of spec.requiredV2Keys) { + if (!v2Env.has(req.key)) { + missing.push(`${channel}:${req.key} (${req.where})`); + } + } + + // Copy on-disk auth files + for (const candidate of spec.candidatePaths) { + const copied = copyGlob(v1Path, process.cwd(), candidate); + filesCopied += copied.length; + } + + channelsProcessed++; + } + + // Sync to data/env/env + if (fs.existsSync(v2EnvPath)) { + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { /* non-fatal */ } + } + + console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`); + if (missing.length > 0) { + console.log(`MISSING:${missing.join(',')}`); + } +} + +main(); diff --git a/setup/migrate-v2/db.ts b/setup/migrate-v2/db.ts new file mode 100644 index 000000000..141b267b1 --- /dev/null +++ b/setup/migrate-v2/db.ts @@ -0,0 +1,162 @@ +/** + * migrate-v2 step: db + * + * Seed v2.db from v1's registered_groups table. + * Creates agent_groups, messaging_groups, and messaging_group_agents. + * + * Does NOT seed users/user_roles — the /migrate-from-v1 skill handles that. + * + * Idempotent: re-running skips rows that already exist. + * + * Usage: pnpm exec tsx setup/migrate-v2/db.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { createAgentGroup, getAgentGroupByFolder } from '../../src/db/agent-groups.js'; +import { initDb } from '../../src/db/connection.js'; +import { + createMessagingGroup, + createMessagingGroupAgent, + getMessagingGroupAgentByPair, + getMessagingGroupByPlatform, +} from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { + generateId, + parseJid, + triggerToEngage, + JID_PREFIX_TO_CHANNEL, +} from '../migrate-v1/shared.js'; + +interface V1Group { + jid: string; + name: string; + folder: string; + trigger_pattern: string | null; + requires_trigger: number | null; + is_main: number | null; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/db.ts '); + process.exit(1); + } + + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + if (!fs.existsSync(v1DbPath)) { + console.error(`v1 DB not found: ${v1DbPath}`); + process.exit(1); + } + + // Read v1 groups + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + + // v1 schema varies — channel_name was a late addition. Query only the + // columns we know exist in all v1 installs. + const v1Groups = v1Db + .prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups') + .all() as V1Group[]; + v1Db.close(); + + if (v1Groups.length === 0) { + console.log('SKIPPED:no registered groups in v1'); + process.exit(0); + } + + // Init v2 DB + fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); + const v2Db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(v2Db); + + let created = 0; + let reused = 0; + let skipped = 0; + const errors: string[] = []; + + for (const g of v1Groups) { + const parsed = parseJid(g.jid); + if (!parsed) { + skipped++; + errors.push(`Could not parse JID: ${g.jid}`); + continue; + } + + const channelType = parsed.channel_type; + const platformId = parsed.raw.startsWith(`${channelType}:`) + ? parsed.raw + : `${channelType}:${parsed.id}`; + const createdAt = new Date().toISOString(); + + try { + // agent_group — one per folder + let ag = getAgentGroupByFolder(g.folder); + if (!ag) { + createAgentGroup({ + id: generateId('ag'), + name: g.name || g.folder, + folder: g.folder, + agent_provider: null, + created_at: createdAt, + }); + ag = getAgentGroupByFolder(g.folder)!; + } + + // messaging_group — one per (channel_type, platform_id) + let mg = getMessagingGroupByPlatform(channelType, platformId); + if (!mg) { + createMessagingGroup({ + id: generateId('mg'), + channel_type: channelType, + platform_id: platformId, + name: g.name || null, + is_group: 1, + unknown_sender_policy: 'public', + created_at: createdAt, + }); + mg = getMessagingGroupByPlatform(channelType, platformId)!; + } + + // messaging_group_agents — wire them + const existing = getMessagingGroupAgentByPair(mg.id, ag.id); + if (!existing) { + const engage = triggerToEngage({ + trigger_pattern: g.trigger_pattern, + requires_trigger: g.requires_trigger, + }); + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: mg.id, + agent_group_id: ag.id, + engage_mode: engage.engage_mode, + engage_pattern: engage.engage_pattern, + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: createdAt, + }); + created++; + } else { + reused++; + } + } catch (err) { + skipped++; + errors.push(`${g.folder}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + v2Db.close(); + + console.log(`OK:groups=${v1Groups.length},created=${created},reused=${reused},skipped=${skipped}`); + if (errors.length > 0) { + for (const e of errors) console.log(`ERROR:${e}`); + } +} + +main(); diff --git a/setup/migrate-v2/env.ts b/setup/migrate-v2/env.ts new file mode 100644 index 000000000..5ac52f621 --- /dev/null +++ b/setup/migrate-v2/env.ts @@ -0,0 +1,81 @@ +/** + * migrate-v2 step: env + * + * Copy every key from v1 .env into v2 .env. Never overwrites existing v2 + * keys. Idempotent — re-running skips keys already present. + * + * Usage: pnpm exec tsx setup/migrate-v2/env.ts + */ +import fs from 'fs'; +import path from 'path'; + +function parseEnv(text: string): Map { + const out = new Map(); + for (const raw of text.split('\n')) { + const line = raw.trimEnd(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; + out.set(key, line); + } + return out; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/env.ts '); + process.exit(1); + } + + const v1EnvPath = path.join(v1Path, '.env'); + if (!fs.existsSync(v1EnvPath)) { + console.log('SKIPPED:no v1 .env'); + process.exit(0); + } + + const v2EnvPath = path.join(process.cwd(), '.env'); + const v1Lines = parseEnv(fs.readFileSync(v1EnvPath, 'utf-8')); + const v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; + const v2Lines = parseEnv(v2Text); + + const copied: string[] = []; + const skipped: string[] = []; + const appended: string[] = []; + + const BLOCK_START = '# ── migrated from v1 ──'; + const alreadyMigrated = v2Text.includes(BLOCK_START); + + for (const [key, raw] of v1Lines) { + if (v2Lines.has(key)) { + skipped.push(key); + continue; + } + copied.push(key); + appended.push(raw); + } + + if (appended.length > 0) { + let result = v2Text; + if (result && !result.endsWith('\n')) result += '\n'; + if (!alreadyMigrated) result += `\n${BLOCK_START}\n`; + result += appended.join('\n') + '\n'; + fs.writeFileSync(v2EnvPath, result); + } + + // Sync to data/env/env (container reads from here) + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + try { + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); + } catch { + // Non-fatal + } + + console.log(`OK:copied=${copied.length},skipped=${skipped.length}`); + if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`); +} + +main(); diff --git a/setup/migrate-v2/groups.ts b/setup/migrate-v2/groups.ts new file mode 100644 index 000000000..beb88bea6 --- /dev/null +++ b/setup/migrate-v2/groups.ts @@ -0,0 +1,120 @@ +/** + * migrate-v2 step: groups + * + * Copy v1 group folders into v2. + * - v1 CLAUDE.md → v2 CLAUDE.local.md (v2 composes CLAUDE.md at spawn) + * - v1 container_config → .v1-container-config.json sidecar + * - All other files copied (no overwrite) + * - Also copies global/ if it exists + * + * Idempotent — does not overwrite files that already exist in v2. + * + * Usage: pnpm exec tsx setup/migrate-v2/groups.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); + +/** Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. */ +function copyTree(src: string, dst: string): number { + let written = 0; + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP_NAMES.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + + if (entry.isDirectory()) { + written += copyTree(s, d); + continue; + } + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/groups.ts '); + process.exit(1); + } + + const v1GroupsDir = path.join(v1Path, 'groups'); + const v2GroupsDir = path.join(process.cwd(), 'groups'); + + if (!fs.existsSync(v1GroupsDir)) { + console.log('SKIPPED:no v1 groups/ directory'); + process.exit(0); + } + + // Get all folders from v1 DB to know which groups are registered + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + const registeredFolders = new Set(); + if (fs.existsSync(v1DbPath)) { + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + const rows = v1Db + .prepare('SELECT folder, container_config FROM registered_groups') + .all() as Array<{ folder: string; container_config: string | null }>; + const containerConfigs = new Map(); + for (const r of rows) { + registeredFolders.add(r.folder); + containerConfigs.set(r.folder, r.container_config); + } + v1Db.close(); + + // Write container.json from v1 container_config. + // The additionalMounts shape is identical between v1 and v2. + for (const [folder, config] of containerConfigs) { + if (!config) continue; + const v2Folder = path.join(v2GroupsDir, folder); + const containerJson = path.join(v2Folder, 'container.json'); + if (fs.existsSync(containerJson)) continue; + fs.mkdirSync(v2Folder, { recursive: true }); + try { + const parsed = JSON.parse(config) as Record; + fs.writeFileSync(containerJson, JSON.stringify(parsed, null, 2)); + } catch { + // Unparseable config — write as sidecar for the skill to handle + fs.writeFileSync(path.join(v2Folder, '.v1-container-config.json'), config); + } + } + } + + // Copy all v1 group folders (registered + global + any extras) + let foldersCopied = 0; + let claudesMigrated = 0; + let filesCopied = 0; + + for (const entry of fs.readdirSync(v1GroupsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = entry.name; + const v1Folder = path.join(v1GroupsDir, folder); + const v2Folder = path.join(v2GroupsDir, folder); + + fs.mkdirSync(v2Folder, { recursive: true }); + + // CLAUDE.md → CLAUDE.local.md + const v1Claude = path.join(v1Folder, 'CLAUDE.md'); + const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); + if (fs.existsSync(v1Claude) && !fs.existsSync(v2Local)) { + fs.copyFileSync(v1Claude, v2Local); + claudesMigrated++; + } + + // Copy everything else + filesCopied += copyTree(v1Folder, v2Folder); + foldersCopied++; + } + + console.log(`OK:folders=${foldersCopied},claudes=${claudesMigrated},files=${filesCopied}`); +} + +main(); diff --git a/setup/migrate-v2/select-channels.ts b/setup/migrate-v2/select-channels.ts new file mode 100644 index 000000000..eecf1abb5 --- /dev/null +++ b/setup/migrate-v2/select-channels.ts @@ -0,0 +1,63 @@ +/** + * migrate-v2: interactive channel selection via clack multiselect. + * + * Writes selected channel names (one per line) to the file path given as + * the first argument. Clack renders to the terminal normally. + * + * If NANOCLAW_CHANNELS env var is set (comma-separated names), skips the + * prompt and writes those directly. + * + * Usage: pnpm exec tsx setup/migrate-v2/select-channels.ts + */ +import fs from 'fs'; + +import * as p from '@clack/prompts'; + +const CHANNELS = [ + { value: 'telegram', label: 'Telegram' }, + { value: 'discord', label: 'Discord' }, + { value: 'slack', label: 'Slack' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'teams', label: 'Microsoft Teams' }, + { value: 'matrix', label: 'Matrix' }, + { value: 'imessage', label: 'iMessage' }, + { value: 'webex', label: 'Webex' }, + { value: 'gchat', label: 'Google Chat' }, + { value: 'resend', label: 'Resend (email)' }, + { value: 'github', label: 'GitHub' }, + { value: 'linear', label: 'Linear' }, + { value: 'whatsapp-cloud', label: 'WhatsApp Cloud API' }, +]; + +const VALID_NAMES = new Set(CHANNELS.map((c) => c.value)); + +async function main(): Promise { + const outFile = process.argv[2]; + if (!outFile) { + console.error('Usage: tsx setup/migrate-v2/select-channels.ts '); + process.exit(1); + } + + // Non-interactive: NANOCLAW_CHANNELS="telegram,discord" + const envChannels = process.env.NANOCLAW_CHANNELS?.trim(); + if (envChannels) { + const names = envChannels.split(',').map((s) => s.trim()).filter((s) => VALID_NAMES.has(s)); + fs.writeFileSync(outFile, names.join('\n') + '\n'); + return; + } + + const selected = await p.multiselect({ + message: 'Which channels do you want to set up?', + options: CHANNELS, + required: false, + }); + + if (p.isCancel(selected)) { + fs.writeFileSync(outFile, ''); + return; + } + + fs.writeFileSync(outFile, (selected as string[]).join('\n') + '\n'); +} + +main(); diff --git a/setup/migrate-v2/sessions.ts b/setup/migrate-v2/sessions.ts new file mode 100644 index 000000000..0299dec1f --- /dev/null +++ b/setup/migrate-v2/sessions.ts @@ -0,0 +1,181 @@ +/** + * migrate-v2 step: sessions + * + * For each v1 session folder, create a proper v2 session: + * 1. Create a sessions row in v2.db (via resolveSession) + * 2. Initialize the session folder (inbound.db, outbound.db, outbox/) + * 3. Write session routing so the container knows where to reply + * 4. Copy v1 .claude/ state into v2's .claude-shared/ directory + * + * v1: data/sessions//.claude/ (settings, conversation history, skills) + * v2: data/v2-sessions//.claude-shared/ + session folder + * + * v1's agent-runner-src/ is NOT copied — v2 uses a completely different + * Bun-based agent-runner. + * + * Idempotent — reuses existing sessions, does not overwrite files. + * + * Usage: pnpm exec tsx setup/migrate-v2/sessions.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb, closeDb } from '../../src/db/connection.js'; +import { getAllAgentGroups } from '../../src/db/agent-groups.js'; +import { getMessagingGroupsByAgentGroup } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { + resolveSession, + writeSessionRouting, + outboundDbPath, +} from '../../src/session-manager.js'; + +const SKIP_NAMES = new Set(['.DS_Store']); + +/** Recursively copy, never overwriting existing files. */ +function copyTree(src: string, dst: string): number { + let written = 0; + if (!fs.existsSync(src)) return 0; + fs.mkdirSync(dst, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (SKIP_NAMES.has(entry.name)) continue; + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + + if (entry.isDirectory()) { + written += copyTree(s, d); + continue; + } + if (fs.existsSync(d)) continue; + fs.copyFileSync(s, d); + written += 1; + } + return written; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/sessions.ts '); + process.exit(1); + } + + const v1SessionsDir = path.join(v1Path, 'data', 'sessions'); + if (!fs.existsSync(v1SessionsDir)) { + console.log('SKIPPED:no v1 data/sessions/ directory'); + process.exit(0); + } + + // Init v2 central DB + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('v2.db not found — run db step first'); + process.exit(1); + } + + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + const agentGroups = getAllAgentGroups(); + const folderToAg = new Map(); + for (const ag of agentGroups) { + folderToAg.set(ag.folder, ag); + } + + let sessionsCreated = 0; + let sessionsReused = 0; + let sessionsSkipped = 0; + let filesCopied = 0; + + for (const entry of fs.readdirSync(v1SessionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = entry.name; + + const ag = folderToAg.get(folder); + if (!ag) { + sessionsSkipped++; + continue; + } + + // Find the messaging groups wired to this agent group + const messagingGroups = getMessagingGroupsByAgentGroup(ag.id); + if (messagingGroups.length === 0) { + sessionsSkipped++; + continue; + } + + // Create a session for each messaging group (v1 had one session per + // folder, v2 has one per agent_group + messaging_group pair) + for (const mg of messagingGroups) { + const { session, created } = resolveSession(ag.id, mg.id, null, 'shared'); + + if (created) { + // Write routing so the container knows where to reply + writeSessionRouting(ag.id, session.id); + sessionsCreated++; + } else { + sessionsReused++; + } + } + + // Copy v1 .claude/ state into v2's .claude-shared/ directory + // This is per-agent-group, shared across all sessions for that group + const v1ClaudeDir = path.join(v1SessionsDir, folder, '.claude'); + if (fs.existsSync(v1ClaudeDir)) { + const v2ClaudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared'); + filesCopied += copyTree(v1ClaudeDir, v2ClaudeDir); + + // v1 containers worked in /workspace/group, v2 works in /workspace/agent. + // Claude Code stores sessions under projects//. Copy the v1 + // project dir to the v2 path so Claude Code finds the conversation history. + const projectsDir = path.join(v2ClaudeDir, 'projects'); + const v1ProjectDir = path.join(projectsDir, '-workspace-group'); + const v2ProjectDir = path.join(projectsDir, '-workspace-agent'); + if (fs.existsSync(v1ProjectDir) && !fs.existsSync(v2ProjectDir)) { + filesCopied += copyTree(v1ProjectDir, v2ProjectDir); + } + + // Write the v1 Claude Code session ID as the continuation in outbound.db + // so the agent-runner resumes the exact same conversation. + // The session ID is the JSONL filename (without extension) under the + // project dir. + const sourceDir = fs.existsSync(v2ProjectDir) ? v2ProjectDir : v1ProjectDir; + if (fs.existsSync(sourceDir)) { + const jsonlFiles = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.jsonl')); + if (jsonlFiles.length > 0) { + // Use the most recent JSONL file (by mtime from v1) + const v1SessionId = jsonlFiles + .map((f) => ({ + name: f.replace('.jsonl', ''), + mtime: fs.statSync(path.join(sourceDir, f)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime)[0].name; + + // Write into each v2 session's outbound.db for this agent group + const sessions = getMessagingGroupsByAgentGroup(ag.id); + for (const mg of sessions) { + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const obPath = outboundDbPath(ag.id, session.id); + if (fs.existsSync(obPath)) { + const ob = new Database(obPath); + ob.prepare( + "INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES ('continuation:claude', ?, ?)", + ).run(v1SessionId, new Date().toISOString()); + ob.close(); + } + } + } + } + } + } + + closeDb(); + + console.log(`OK:created=${sessionsCreated},reused=${sessionsReused},skipped=${sessionsSkipped},files=${filesCopied}`); +} + +main(); diff --git a/setup/migrate-v2/switchover-prompt.ts b/setup/migrate-v2/switchover-prompt.ts new file mode 100644 index 000000000..996b30219 --- /dev/null +++ b/setup/migrate-v2/switchover-prompt.ts @@ -0,0 +1,53 @@ +/** + * migrate-v2: service switchover prompts. + * + * Writes a single word to the output file: + * --offer-switch → "switch" | "skip" + * --keep-or-revert → "keep" | "revert" + * + * Clack renders to the terminal normally. + * + * Usage: pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch + */ +import fs from 'fs'; + +import * as p from '@clack/prompts'; + +async function main(): Promise { + const mode = process.argv[2]; + const outFile = process.argv[3]; + + if (!outFile) { + console.error('Usage: tsx setup/migrate-v2/switchover-prompt.ts <--offer-switch|--keep-or-revert> '); + process.exit(1); + } + + if (mode === '--offer-switch') { + const answer = await p.select({ + message: 'Want to stop the v1 service and start v2 so you can test?', + options: [ + { value: 'switch', label: 'Yes, switch to v2 now', hint: 'you can switch back after' }, + { value: 'skip', label: 'No, skip for now', hint: 'start v2 manually later' }, + ], + }); + fs.writeFileSync(outFile, p.isCancel(answer) ? 'skip' : String(answer)); + return; + } + + if (mode === '--keep-or-revert') { + const answer = await p.select({ + message: 'Keep v2 running, or switch back to v1?', + options: [ + { value: 'keep', label: 'Keep v2', hint: 'v1 stays stopped' }, + { value: 'revert', label: 'Switch back to v1', hint: 'stop v2, restart v1' }, + ], + }); + fs.writeFileSync(outFile, p.isCancel(answer) ? 'revert' : String(answer)); + return; + } + + console.error('Usage: --offer-switch | --keep-or-revert'); + process.exit(1); +} + +main(); diff --git a/setup/migrate-v2/tasks.ts b/setup/migrate-v2/tasks.ts new file mode 100644 index 000000000..9ba570a65 --- /dev/null +++ b/setup/migrate-v2/tasks.ts @@ -0,0 +1,158 @@ +/** + * migrate-v2 step: tasks + * + * Port v1 scheduled_tasks into v2 session inbound DBs. + * + * v1: scheduled_tasks table (schedule_type, schedule_value, next_run) + * v2: messages_in rows with kind='task' in per-session inbound.db + * + * Requires: db step must have run first (agent_groups + messaging_groups seeded). + * + * Usage: pnpm exec tsx setup/migrate-v2/tasks.ts + */ +import fs from 'fs'; +import path from 'path'; + +import Database from 'better-sqlite3'; + +import { DATA_DIR } from '../../src/config.js'; +import { initDb, closeDb } from '../../src/db/connection.js'; +import { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; +import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; +import { runMigrations } from '../../src/db/migrations/index.js'; +import { insertTask } from '../../src/modules/scheduling/db.js'; +import { openInboundDb, resolveSession } from '../../src/session-manager.js'; +import { parseJid, v2PlatformId } from '../migrate-v1/shared.js'; + +interface V1Task { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + schedule_type: string; + schedule_value: string; + next_run: string | null; + status: string; + context_mode: string | null; + script: string | null; +} + +function toCron(t: V1Task): { processAfter: string; recurrence: string | null } | null { + const now = new Date().toISOString(); + + if (t.schedule_type === 'cron') { + const fields = t.schedule_value.trim().split(/\s+/).length; + if (fields < 5 || fields > 6) return null; + return { processAfter: t.next_run || now, recurrence: t.schedule_value.trim() }; + } + + if (t.schedule_type === 'interval') { + const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2]; + if (!n || n < 1) return null; + let cron: string | null = null; + if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; + else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; + else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; + if (!cron) return null; + return { processAfter: t.next_run || now, recurrence: cron }; + } + + if (t.schedule_type === 'once' || t.schedule_type === 'at') { + return { processAfter: t.next_run || t.schedule_value || now, recurrence: null }; + } + + return null; +} + +function main(): void { + const v1Path = process.argv[2]; + if (!v1Path) { + console.error('Usage: tsx setup/migrate-v2/tasks.ts '); + process.exit(1); + } + + const v1DbPath = path.join(v1Path, 'store', 'messages.db'); + if (!fs.existsSync(v1DbPath)) { + console.log('SKIPPED:no v1 DB'); + process.exit(0); + } + + // Read v1 tasks + const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true }); + const allTasks = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; + v1Db.close(); + + const activeTasks = allTasks.filter((t) => t.status === 'active'); + if (activeTasks.length === 0) { + console.log('SKIPPED:no active tasks'); + process.exit(0); + } + + // Init v2 central DB + const v2DbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(v2DbPath)) { + console.error('v2.db not found — run db step first'); + process.exit(1); + } + const v2Db = initDb(v2DbPath); + runMigrations(v2Db); + + let migrated = 0; + let skipped = 0; + let failed = 0; + + for (const t of activeTasks) { + try { + const ag = getAgentGroupByFolder(t.group_folder); + if (!ag) { skipped++; continue; } + + const parsed = parseJid(t.chat_jid); + if (!parsed) { skipped++; continue; } + + const platformId = v2PlatformId(parsed.channel_type, t.chat_jid); + const mg = getMessagingGroupByPlatform(parsed.channel_type, platformId); + if (!mg) { skipped++; continue; } + + const scheduling = toCron(t); + if (!scheduling) { skipped++; continue; } + + const { session } = resolveSession(ag.id, mg.id, null, 'shared'); + const inboxDb = openInboundDb(ag.id, session.id); + try { + // Idempotence check + const existing = inboxDb + .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") + .get(t.id) as { id: string } | undefined; + if (existing) { skipped++; continue; } + + insertTask(inboxDb, { + id: t.id, + processAfter: scheduling.processAfter, + recurrence: scheduling.recurrence, + platformId, + channelType: parsed.channel_type, + threadId: null, + content: JSON.stringify({ + prompt: t.prompt, + script: t.script ?? null, + migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, + }), + }); + migrated++; + } finally { + inboxDb.close(); + } + } catch (err) { + failed++; + console.error(`TASK_ERROR:${t.id}:${err instanceof Error ? err.message : String(err)}`); + } + } + + closeDb(); + console.log(`OK:active=${activeTasks.length},migrated=${migrated},skipped=${skipped},failed=${failed}`); +} + +main();