mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
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:
@@ -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-*
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user