feat: add migrate-v2.sh — standalone v1 → v2 migration script

New entry point: `bash migrate-v2.sh` from the v2 checkout.
Replaces the old setup-embedded migration flow with a standalone
4-phase script + rewritten Claude skill for the interactive parts.

Phase 0: Bootstrap (Node/pnpm/deps via setup.sh) + find v1
Phase 1: Core state (env, DB, groups, sessions, tasks)
Phase 2: Channels (clack multiselect, auth copy, code install)
Phase 3: Infrastructure (OneCLI, auth, Docker, skills, container build)
Service switchover: stop v1 → start v2 → test → keep or revert
Phase 4: Handoff → exec claude "/migrate-from-v1"

The skill handles: owner seeding, access policy, CLAUDE.local.md
cleanup, container config validation, fork customization porting.

Key fixes found during testing:
- triggerToEngage: requires_trigger=0 must override non-empty pattern
- unknown_sender_policy defaults to 'public' (strict drops all msgs
  before owner is seeded)
- Service revert must stop v2 (parse unit name from step log, not
  early tsx one-liner that can fail)
- Session continuity: copy JSONL from -workspace-group/ to
  -workspace-agent/ and write continuation:claude into outbound.db
- container_config.additionalMounts written directly to container.json
  (same shape in v1 and v2)
- EXIT trap writes handoff.json; explicit write_handoff before exec

Includes migrate-v2-reset.sh for dev iteration and docs/migration-dev.md
for testing/debugging reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-01 20:13:38 +00:00
parent 36e731c02d
commit 1d73b2986a
13 changed files with 1976 additions and 76 deletions
+170 -73
View File
@@ -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/<folder>/` 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-<channel>.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/<step>.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/<step>.ts` and accept `<v1_path>` 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-\<channel\> 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 `<primary channel>` 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 `<channel_type>:<handle>`.
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 `<channel_type>:<platform_handle>`. Each channel populates this differently:
- **Telegram**: `telegram:<numeric_user_id>` (e.g. `telegram:6037840640`)
- **Discord**: `discord:<snowflake_user_id>` (e.g. `discord:123456789012345678`)
- **WhatsApp**: `whatsapp:<phone>@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`)
- **Slack**: `slack:<user_id>` (e.g. `slack:U04ABCDEF`)
- **Others**: `<channel_type>:<platform_id>`
**Steps:**
1. Query `users` table: `SELECT id, kind, display_name FROM users`.
2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `<display_name>` (`<id>`) 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 ('<user_id>', '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-<step>.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 `<handoff.v1_path>/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/<folder>/container.json` format.
```sql
-- v1: unique senders per chat (excluding bot messages)
SELECT DISTINCT sender, sender_name
FROM messages
WHERE chat_jid = '<v1_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: `<channel_type>:<sender>`.
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 = '<chosen_policy>'
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
```
## 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 <v1_path>
git remote -v # identify the upstream remote
git log --oneline <upstream>/main..HEAD # commits ahead of upstream
git remote -v
git log --oneline <upstream>/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-<date>.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 <name>
```
| 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-<date>.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-*
```
+139
View File
@@ -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:<details>`, `SKIPPED:<reason>`, 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/<ag_id>/.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-<hash>` — find it: `systemctl --user list-units 'nanoclaw*'`
2. Manually stop: `systemctl --user stop <unit> && systemctl --user disable <unit>`
3. Restart v1: `systemctl --user start nanoclaw`
### Step logs
Each step writes raw output to `logs/migrate-steps/<step>.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`.
+69
View File
@@ -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')"
+641
View File
@@ -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" <<HANDOFF_EOF
{
"version": 1,
"started_at": "$(ts_utc)",
"v1_path": "$V1_PATH",
"v1_version": "$V1_VERSION",
"overall_status": "$overall",
"aborted_at": "$ABORTED_AT",
"source": "migrate-v2.sh",
"channels_installed": [$(printf '"%s",' "${SELECTED_CHANNELS[@]}" 2>/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
+5 -3
View File
@@ -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 };
}
+134
View File
@@ -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 <v1-path> <channel1> [channel2...]
*/
import fs from 'fs';
import path from 'path';
import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js';
function parseEnv(filePath: string): Map<string, string> {
const out = new Map<string, string>();
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 <v1-path> <channel1> [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();
+162
View File
@@ -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 <v1-path>
*/
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 <v1-path>');
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();
+81
View File
@@ -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 <v1-path>
*/
import fs from 'fs';
import path from 'path';
function parseEnv(text: string): Map<string, string> {
const out = new Map<string, string>();
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 <v1-path>');
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();
+120
View File
@@ -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 <v1-path>
*/
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 <v1-path>');
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<string>();
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<string, string | null>();
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<string, unknown>;
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();
+63
View File
@@ -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 <output-file>
*/
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<void> {
const outFile = process.argv[2];
if (!outFile) {
console.error('Usage: tsx setup/migrate-v2/select-channels.ts <output-file>');
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();
+181
View File
@@ -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/<folder>/.claude/ (settings, conversation history, skills)
* v2: data/v2-sessions/<agent_group_id>/.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 <v1-path>
*/
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 <v1-path>');
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<string, { id: string; folder: string }>();
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/<hashed-cwd>/. 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();
+53
View File
@@ -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 <output-file>
*/
import fs from 'fs';
import * as p from '@clack/prompts';
async function main(): Promise<void> {
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> <output-file>');
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();
+158
View File
@@ -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 <v1-path>
*/
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 <v1-path>');
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();