Compare commits

...

4 Commits

Author SHA1 Message Date
gavrielc 5afe51b88d refactor(migrate): rewrite migrate-v2.sh for sibling-clone pattern
Previous shape required the script to live inside the v1 tree and
created a git worktree as a subdirectory — which polluted the v1 tree
with a new `upstream` remote, worktree metadata, and the worktree dir
itself. A cancelled run left residue.

New shape: the v2 checkout is a fresh sibling clone of v1. Script runs
from that clone with the v1 path as positional arg 1:

    cd ~/nanoclaw-v2
    bash migrate-v2.sh ~/nanoclaw-v1

Also supports curl-to-bash (BASH_SOURCE check): when piped, the script
clones v2 itself into `$(dirname $V1_ROOT)/nanoclaw-v2` before running.

Result:
  - v1 tree is read-only during the migration (until the final swap).
  - Cancel = `rm -rf ~/nanoclaw-v2`. No cleanup needed in v1.
  - No unwanted remotes or `.git/worktrees/` entries in v1.
  - The driver's --v1-root is always explicit; no cwd-based guessing.
2026-04-22 18:21:45 +03:00
gavrielc f816141119 feat(migrate): add migrate-v2.sh bootstrap
One-shot entry point for v1 users to migrate to v2 without merging v2
into their existing checkout. Fulfills the contract advertised by the
v1-merge STOP banner in CLAUDE.md (commit 0ed00b3), which tells Claude
to route users to `bash migrate-v2.sh` when they hit merge conflicts
from an upstream pull.

Flow:
  1. Preflight — verify v1 state (store/messages.db present, no v2.db),
     Node ≥ 20, upstream remote configured.
  2. Fetch upstream/v2 (ref overridable via NANOCLAW_V2_REF).
  3. `git worktree add .migrate-worktree upstream/v2 --detach`.
  4. `corepack enable pnpm` + `pnpm install --frozen-lockfile` in the
     worktree.
  5. exec `pnpm run migrate:v1-to-v2 -- --v1-root <v1-abs-path>`.

Refuses cleanly when run outside a git repo, against a fresh directory,
or against an existing v2 install. Reuses nanoclaw.sh's color helpers
for visual continuity with the rest of v2's install flow.

Gap this closes: before this commit, the banner advertised a script
that didn't exist. Now the full path (git pull → conflicts → banner →
`git merge --abort` → fetch script into v1 tree → run it) is unblocked.
2026-04-22 18:14:04 +03:00
gavrielc c82faf4d7b Merge remote-tracking branch 'origin/v2' into migrate/v1-to-v2 2026-04-22 18:10:42 +03:00
gavrielc 96dd77c911 feat(migrate): v1→v2 migration driver + skill
Hybrid flow modeled on setup:auto — a scripted driver
(`pnpm run migrate:v1-to-v2`) owns the UX, with two Claude integration
points: offerClaudeAssist() on failure, offerClaudeHandoff() for the
rebuild step and owner-ambiguity `?` escape.

Structure:
  setup/migrate.ts                — sequencer (clack UI + step routing)
  setup/migrate/detect-v1.ts      — v1/v2/mixed/fresh detection
  setup/migrate/jid.ts            — v1 JID → v2 channel_type inference
  setup/migrate/owner-propose.ts  — owner inference (env / is_main / allowlist)
  setup/migrate/extract-v1.ts     — reads v1 DB + git + env → v1-data JSONs
  setup/migrate/seed-v2.ts        — seeds v2 central DB from v1-data
  setup/migrate/guide-compose.ts  — renders .nanoclaw-migrations/guide.md
  .claude/skills/migrate-v1-to-v2 — orchestration doc + reference template

v1 defaults mapped to v2 seeds:
  registered_groups.folder         → agent_groups (deduped by folder)
  registered_groups.jid            → messaging_groups (channel_type from JID)
  trigger_pattern + requires_trigger → engage_mode + engage_pattern (migration 010)
  container_config DB column       → groups/<folder>/container.json (skills: 'all')
  sender-allowlist explicit JIDs   → users + agent_group_members
  is_main / .env OWNER_* / single allowlist entry → user_roles(owner) + user_dms
  v1 groups/<folder>/CLAUDE.md     → v2 groups/<folder>/CLAUDE.local.md

Reuses setup/lib/{runner,claude-assist,claude-handoff,theme}.ts verbatim.
Seeder is idempotent; fails loudly when a required channel adapter isn't
installed via /add-<name>.
2026-04-22 16:21:34 +03:00
11 changed files with 2346 additions and 0 deletions
@@ -0,0 +1,103 @@
<!--
Reference template for `.nanoclaw-migrations/guide.md`.
The actual guide is generated by `setup/migrate/guide-compose.ts`;
this file exists as a structural reference for humans reviewing
the generator output or writing by hand after a driver-less flow.
-->
# NanoClaw v1→v2 Migration Guide
Generated: `<timestamp>`
v1 root: `<absolute path>`
v1 HEAD: `<short sha>`
Owner: `<channel:handle>` (confidence: `<high|medium|low|none>`, source: `<where it came from>`)
---
## Seed plan
**Agent groups** (one per unique v1 `folder`):
- `<folder>``<name>`
- ...
**Messaging groups + wirings** (one per v1 JID):
| channel_type | platform_id | folder | engage_mode | engage_pattern |
|---|---|---|---|---|
| whatsapp | `1555...@s.whatsapp.net` | `main` | mention | — |
| whatsapp | `120363...@g.us` | `family` | pattern | `^@Andy\b` |
> v2.0 replaced the v1 `trigger_rules` JSON column with four explicit
> columns: `engage_mode`, `engage_pattern`, `sender_scope`,
> `ignored_message_policy`. Migration 010 backfills from v1 data; our
> seeder writes the new columns directly.
## Skills to install (in order)
**Channel skills** (required by seed):
- [ ] `/add-<channel>` — provides channel_type `<channel>`
**Other previously-applied skills:**
- [ ] `/add-<name>` (was `skill/<name>`)
## Reapply-as-is
- Non-secret `.env` keys: `ASSISTANT_NAME`, `TZ`, `CONTAINER_IMAGE`, `IDLE_TIMEOUT`, `MAX_CONCURRENT_CONTAINERS`.
(Secrets — `ANTHROPIC_API_KEY`, channel tokens — do **not** copy. Use `/init-onecli` and channel setup flows.)
- `groups/<folder>/CLAUDE.md` → v2 `groups/<folder>/CLAUDE.local.md`.
v2 regenerates `CLAUDE.md` at spawn via `composeGroupClaudeMd()`; per-group agent memory lives in `CLAUDE.local.md`.
- User-authored skill directories under `.claude/skills/`.
## Translate
- **Triggers** — seeded automatically (v1 `TRIGGER_PATTERN` → v2 per-wiring `engage_mode` + `engage_pattern`).
- **Container configs** — seeded automatically (v1 `container_config` DB column → `groups/<folder>/container.json` with `skills: 'all'` default).
- **Sender allowlist** — seeded automatically when there are explicit JID entries. Wildcard `"*"` allowlists leave memberships empty; control access via `unknown_sender_policy` on each `messaging_group`.
- **Owner + admin** — seeded automatically. Owner gets `user_roles(role='owner')`; `NANOCLAW_ADMIN_USER_IDS` appended to `.env`.
## Rebuild
Files changed since the v1 merge base. Each needs an equivalent v2 location — v2's module system is the target, not the same v1 file path.
### `<customization title>`
- **v1 file:** `src/<path>` — no longer exists on v2
- **Intent:** what the user wanted
- **v2 location:** `src/modules/<name>/...` or `registerDeliveryAction` or `registerResponseHandler` or `registerTools` or `setAccessGate`
- **How to apply:** (code snippets or step-by-step)
## Deferred
- **Scheduled tasks** — v1 `scheduled_tasks` rows are in `v1-data/scheduled-tasks.json`. v2 stores tasks in per-session `messages_in` (kind='task'), not the central DB. After first DM contact with the agent, paste the list so it can recreate them via its scheduling tool.
- **Chat metadata** — v2 has no central `chats` table. History lives in `store.v1-backup/messages.db` (preserved) if you need to extract it.
- **WhatsApp Baileys auth state** — depends on which WhatsApp skill you install (`/add-whatsapp` vs `/add-whatsapp-cloud`). May need re-pairing.
## Dropped
Customizations against v1-only surfaces — do not reimplement literally:
- Edits to `src/credential-proxy.ts` — replaced by OneCLI vault.
- Edits to `src/ipc.ts` or anything reading/writing `data/ipc/` — IPC removed; containers communicate via `inbound.db`/`outbound.db`.
- Edits to `src/task-scheduler.ts` — scheduling is a module (`src/modules/scheduling/`).
- Edits to `src/logger.ts` or pino customizations — pino removed.
- Baileys patches in `src/channels/whatsapp.ts` — adapter moved to the `channels` branch; customize there.
If the *intent* behind a dropped customization still applies, re-express it against the v2 module system. The rebuild step exists for that.
## Rollback
Pre-migration tag: `pre-v2-<hash>-<ts>` (created in the `safety` step).
```bash
git reset --hard pre-v2-<hash>-<ts>
rm -f data/v2.db
mv store.v1-backup store 2>/dev/null || true
mv data/ipc.v1-backup data/ipc 2>/dev/null || true
# macOS:
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux:
systemctl --user restart nanoclaw
```
+135
View File
@@ -0,0 +1,135 @@
---
name: migrate-v1-to-v2
description: Migrate a NanoClaw v1 install to v2. v2 is a ground-up rewrite — new DB schema, new entity model (users/roles/DMs), channels moved off trunk, npm→pnpm, Node→Bun container, credential proxy → OneCLI. Runs a structured worktree-based flow (`pnpm run migrate:v1-to-v2`) that extracts v1 state, seeds v2's central DB, and hands off to Claude for any source customizations that need rebuilding. Triggers on "migrate to v2", "upgrade to v2", "v1 to v2".
---
# Migrate v1 → v2
This skill is a **hybrid flow**, modeled on `setup:auto`: the heavy lifting is a scripted driver (`setup/migrate.ts`), and this markdown's job is to orient you before handing control over to it.
The driver owns the visible UX — spinners, notes, prompts — and emits a progression log at `logs/setup.log`. You stay available in two specific spots:
1. **On failure**, the driver calls `offerClaudeAssist()` which spawns `claude -p` non-interactively to diagnose and suggest a command. If the user accepts, the driver re-runs the failed step.
2. **For the rebuild step**, the driver calls `offerClaudeHandoff()` which spawns interactive Claude with the migration guide pre-loaded as a system-prompt append. The user types `/exit` in Claude when they're done to return to the flow.
Your role when this skill is invoked is to (a) decide whether this is actually the right skill, (b) set up the v2 worktree, (c) start the driver, and (d) stay available for handoffs as Claude.
## When to use this skill
Trigger: the user is on v1 (NanoClaw < 2.0.0) and wants to move to v2 (≥ 2.0.0).
Diagnose by running these in parallel:
```
ls -la store/messages.db # v1 DB — should exist
ls -la data/v2.db # v2 DB — should NOT exist
grep -E "^\s*\"version\":" package.json
```
| Signal | Skill |
|---|---|
| `store/messages.db` exists + `package.json` version `1.x` | **this skill** |
| `data/v2.db` exists, user wants routine upgrade | `/update-nanoclaw` |
| Fresh clone, no install state | `/setup` or `bash nanoclaw.sh` |
| Heavily customized fork, user already on v2, wants clean-base replay | `/migrate-nanoclaw` |
If the user is on v1 but has limited customizations (just channel skills + some CLAUDE.md edits), this skill is still the right tool — the structural break is what matters, not the size of the diff.
## Flow overview
```
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ v1 install │───▶│ v2 worktree │───▶│ swap (user) │
│ (stay put) │ │ (.migrate-…) │ │ v2 worktree │
└─────────────┘ └──────────────┘ │ replaces v1 │
reads only seed + build └──────────────┘
```
1. Add `upstream` remote if missing, fetch.
2. `git worktree add .migrate-worktree upstream/v2 --detach` (branch name may vary — check `git branch -r | grep v2`).
3. `cd .migrate-worktree && pnpm install --frozen-lockfile`.
4. Install channel skills matching what v1 used (see `v1-data/summary.md` after extract) via `/add-<name>` inside the worktree.
5. `pnpm run migrate:v1-to-v2` — runs the scripted driver.
6. Run `/init-onecli` inside the worktree to move credentials into the OneCLI vault.
7. `./container/build.sh` inside the worktree — forces a fresh Bun-based image.
8. Live-smoke-test from the worktree with symlinked data dirs (see driver outro).
9. Swap: rename v1 data dirs to `.v1-backup`, remove the worktree, `git reset --hard <upgrade-commit>` in the original tree, restore `.nanoclaw-migrations/`.
10. Restart service.
Steps 5 + 6 + 7 are where the driver does most of its work. Steps 14 and 810 are things you orchestrate.
## What the driver does
`setup/migrate.ts` runs these steps in order (each is skippable via `NANOCLAW_MIGRATE_SKIP=step1,step2,…`):
| Step | What it does | Can fail? |
|---|---|---|
| `preflight` | Detect v1/v2/mixed/fresh; dirty-tree check on v1 | yes → abort |
| `extract` | Read `store/messages.db`, `.env` (non-secret keys), `~/.config/nanoclaw/*`, git log. Write `.nanoclaw-migrations/v1-data/*.json` into the v1 tree | yes → claude-assist |
| `owner` | Confirm/prompt for owner user_id (with `?` → handoff if unknown) | no (prompts until answered) |
| `guide` | Compose `.nanoclaw-migrations/guide.md` from extracted state | yes → claude-assist |
| `safety` | `git tag pre-v2-<hash>-<ts>` + backup branch in v1 tree | no |
| `seed` | Run migrations + seed v2 central DB from v1-data | yes → claude-assist + retry |
| `copy` | Copy v1 `groups/<folder>/CLAUDE.md` → v2 `CLAUDE.local.md`; user-authored skills; additive `.env` merge; append `NANOCLAW_ADMIN_USER_IDS` | no |
| `rebuild` | For customized source files, offer **interactive Claude handoff** with the guide + customization list pre-loaded | user-skippable |
| `verify` | `pnpm run build && pnpm test` in the worktree; on failure, claude-assist | yes → claude-assist |
The driver does **not** run the swap. That's left to the user after they've live-smoke-tested from the worktree, because the swap is destructive and benefits from human judgement.
## Key v1 → v2 mappings the driver handles
- `registered_groups.folder``agent_groups` (dedupe — one AG per unique folder, may span multiple JIDs)
- `registered_groups.jid``messaging_groups` (channel_type inferred from JID; `wechat` added post-v2.0)
- `registered_groups.trigger_pattern` + `requires_trigger``messaging_group_agents.engage_mode` + `engage_pattern` (new in v2.0: replaces `trigger_rules` JSON column; see migration 010)
- `registered_groups.container_config` (DB column) → `groups/<folder>/container.json` (new shape — `skills: 'all'` default)
- `sender-allowlist.json` explicit entries → `users` + `agent_group_members`
- Owner (inferred from `.env` / `is_main` / single allowlist entry, or prompted) → `users` + `user_roles(owner)` + `user_dms` + `NANOCLAW_ADMIN_USER_IDS`
- v1 `groups/<folder>/CLAUDE.md` → v2 `groups/<folder>/CLAUDE.local.md` (v2 regenerates `CLAUDE.md` at spawn via `composeGroupClaudeMd()`)
- `scheduled_tasks` → deferred (v2 stores them in per-session `messages_in` rows, not central DB — driver writes them out for the agent to recreate via its scheduling tool on first contact)
## Orchestration playbook
When the user says "migrate to v2":
1. Run the diagnosis commands above. If this isn't a v1 install, redirect to the right skill.
2. Check that the user has committed or stashed any pending changes in the v1 tree. Offer to do this for them.
3. Add the `upstream` remote if missing (default URL: `https://github.com/qwibitai/nanoclaw.git`). Fetch.
4. Determine the v2 ref — prefer an explicit v2 release tag if available (e.g. `v2.0.0`), else `upstream/v2`, else `upstream/main` if v2 has already been merged.
5. Create the worktree: `git worktree add .migrate-worktree <v2-ref> --detach`.
6. `cd .migrate-worktree && pnpm install --frozen-lockfile`.
7. Start the driver: `cd .migrate-worktree && pnpm run migrate:v1-to-v2 -- --v1-root <v1-abs-path>` (the driver defaults `--v1-root` to `..` when run from a worktree dir named `.migrate-worktree`, so the flag is usually optional).
8. **Stay available for the driver's handoff calls.** The driver uses `claude -p` for failures and interactive `claude` for the rebuild step. When the user returns from an interactive handoff, they'll be back in the driver flow.
9. After the driver completes, walk the user through the remaining manual steps (install channel skills if they weren't already, `/init-onecli`, `./container/build.sh`, live smoke test, swap, service restart).
## When to hand off to Claude mid-flow
The driver invokes Claude automatically in these situations:
- **Any step fails** — `offerClaudeAssist` spawns `claude -p` with the step name, error message, and a short list of file references. The user sees a suggested command in a clack note and can run it (via `setup/run-suggested.sh`). If they accept, the driver re-runs the failing step.
- **Owner is ambiguous** — if the driver can't infer an owner and the user types `?` at the prompt, it opens interactive Claude with the extracted JSONs as context.
- **Rebuild step** — always prompts "Hand off to Claude now?"; if yes, spawns interactive Claude with `guide.md` + `git-customizations.json` + `docs/module-contract.md` + `docs/architecture.md` pre-loaded.
You (the orchestrating Claude for this skill) can also proactively offer to `cat` the migration guide and discuss it with the user between the driver's `guide` and `safety` steps. That's outside the driver's control — the user can always pause the flow with Ctrl-C and resume later via `NANOCLAW_MIGRATE_SKIP`.
## Rollback
Pre-migration tag is always created in step `safety`. After swap, the user can fully undo with:
```bash
git reset --hard pre-v2-<hash>-<ts>
rm -f data/v2.db
mv store.v1-backup store 2>/dev/null || true
mv data/ipc.v1-backup data/ipc 2>/dev/null || true
# macOS:
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux:
systemctl --user restart nanoclaw
```
Before swap, rollback is trivial — just delete the worktree and ignore `.nanoclaw-migrations/`.
## Extending
The driver lives at `setup/migrate.ts`; library code at `setup/migrate/`. Mirrors `setup/auto.ts` + `setup/channels/` — look there for the pattern if you need to add a step (e.g. a dedicated step for moving WhatsApp Baileys auth state, or for running `scripts/init-first-agent.ts` against the seeded rows).
Reuses `setup/lib/{runner,claude-assist,claude-handoff,theme}.ts` directly — those primitives don't know anything specific to setup-vs-migrate.
Executable
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
#
# NanoClaw v1 → v2 migration entry point.
#
# Invoked from a fresh v2 clone (a sibling of the v1 tree, not a worktree
# inside it) with the v1 project path as the one positional argument:
#
# cd ~/nanoclaw-v2 # the v2 clone
# bash migrate-v2.sh ~/nanoclaw-v1
#
# Or via curl-to-bash:
#
# curl -sSL https://raw.githubusercontent.com/qwibitai/nanoclaw/main/migrate-v2.sh \
# | bash -s -- ~/nanoclaw-v1
# # (in curl mode, the script clones a fresh v2 sibling itself)
#
# The v1 tree is read-only during the migration — no new remotes, no
# worktree metadata, no files added. The v2 clone is where everything
# happens: pnpm install, central DB seed, CLAUDE.local.md copies. The
# final swap (if the operator chooses) is a separate manual step,
# guided by `.nanoclaw-migrations/guide.md` inside the v1 tree.
set -euo pipefail
# ─── color helpers (matches nanoclaw.sh) ────────────────────────────────
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
gray() { use_ansi && printf '\033[90m%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"; }
brand_bold() {
if use_ansi; then
if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then
printf '\033[1;38;2;43;183;206m%s\033[0m' "$1"
else
printf '\033[1;36m%s\033[0m' "$1"
fi
else
printf '%s' "$1"
fi
}
step() { printf ' %s %s\n' "$(gray '◆')" "$1"; }
ok() { printf ' %s %s\n' "$(gray '◇')" "$1"; }
die() {
printf '\n %s %s\n' "$(red '✗')" "$1"
[ "${2:-}" ] && printf ' %s\n' "$(dim "$2")"
printf '\n'
exit 1
}
# ─── parse + validate args ──────────────────────────────────────────────
V1_ROOT_ARG="${1:-}"
if [ -z "$V1_ROOT_ARG" ]; then
die "Missing v1 project path." "Usage: bash migrate-v2.sh <path-to-v1-checkout>"
fi
# Absolute-ify so the driver doesn't have to care about the caller's cwd.
if [ ! -d "$V1_ROOT_ARG" ]; then
die "v1 path doesn't exist: $V1_ROOT_ARG" "Pass the absolute path to your v1 NanoClaw checkout."
fi
V1_ROOT="$(cd "$V1_ROOT_ARG" && pwd)"
# The v2 clone is wherever this script lives. Assumption: the user either
# cloned the repo and `cd`'d in, or the curl-to-bash one-liner cloned for
# them (see below).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# curl-to-bash detection: $0 is `bash` (or similar), not the path to us.
# In that case BASH_SOURCE is usually unreliable, and we need to clone v2
# ourselves into a fresh sibling of v1.
if [ -z "${BASH_SOURCE:-}" ] || [ "${BASH_SOURCE[0]}" = "$0" ] && [ ! -f "$SCRIPT_DIR/package.json" ]; then
# Curl-to-bash path.
CLONE_URL="${NANOCLAW_V2_REPO_URL:-https://github.com/qwibitai/nanoclaw.git}"
CLONE_REF="${NANOCLAW_V2_REF:-main}"
V2_ROOT="$(dirname "$V1_ROOT")/nanoclaw-v2"
if [ -d "$V2_ROOT" ]; then
die "Sibling $V2_ROOT already exists." "Remove it first, or cd into a v2 clone and run bash migrate-v2.sh <v1-path>."
fi
step "curl-to-bash mode detected — cloning v2 into $V2_ROOT"
git clone --branch "$CLONE_REF" --depth 1 "$CLONE_URL" "$V2_ROOT" 2>&1 | sed 's/^/ /' || \
die "Couldn't clone v2." "Check network and the repo URL (override with NANOCLAW_V2_REPO_URL)."
cd "$V2_ROOT"
else
cd "$SCRIPT_DIR"
V2_ROOT="$SCRIPT_DIR"
fi
# ─── intro ──────────────────────────────────────────────────────────────
printf '\n %s%s %s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" "$(dim '· v1 → v2 migration')"
printf ' %s\n' "$(dim "v1: $V1_ROOT")"
printf ' %s\n\n' "$(dim "v2: $V2_ROOT")"
# ─── sanity-check both sides ────────────────────────────────────────────
# cwd must look like a v2 clone — package.json with version 2.x + the
# migrate driver on disk. This catches the easy mistake of running from a
# v1 checkout or a completely unrelated directory.
if [ ! -f "package.json" ]; then
die "$V2_ROOT doesn't look like a NanoClaw checkout." "cd into the v2 clone and try again, or re-run via curl-to-bash."
fi
PKG_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "")
case "$PKG_VERSION" in
2.*) ok "v2 clone detected (package.json @$PKG_VERSION)" ;;
*) die "$V2_ROOT is package version '$PKG_VERSION', not 2.x." "Clone or checkout the v2 branch first." ;;
esac
if [ ! -f "setup/migrate.ts" ]; then
die "Missing setup/migrate.ts in $V2_ROOT." "This v2 checkout is too old — pull the latest."
fi
# v1 side — refuse if the shape doesn't match.
if [ -f "$V1_ROOT/data/v2.db" ] && [ ! -f "$V1_ROOT/store/messages.db" ]; then
die "$V1_ROOT is already v2." "Delete data/v2.db and re-run if you want to re-seed."
fi
if [ ! -f "$V1_ROOT/store/messages.db" ] && [ ! -f "$V1_ROOT/.env" ]; then
die "Can't find a v1 install at $V1_ROOT." "Expected store/messages.db + .env. Check the path and retry."
fi
ok "v1 install detected"
# ─── Node + pnpm ────────────────────────────────────────────────────────
if ! command -v node >/dev/null 2>&1; then
die "Node isn't installed." "Install Node 20+ and retry (v2 uses pnpm via corepack, which ships with Node 20)."
fi
NODE_MAJOR=$(node -v | sed -E 's/^v([0-9]+)\..*/\1/')
if [ "${NODE_MAJOR:-0}" -lt 20 ]; then
die "Node $(node -v) is too old." "v2 requires Node ≥ 20. Upgrade and retry."
fi
ok "Node $(node -v)"
step "Enabling pnpm via corepack…"
if ! corepack enable pnpm >/dev/null 2>&1; then
corepack enable pnpm || \
die "corepack enable pnpm failed." "Run 'sudo corepack enable pnpm' manually and retry."
fi
ok "pnpm $(pnpm --version 2>/dev/null || echo '(version unknown)')"
# ─── install deps ───────────────────────────────────────────────────────
if [ ! -d "node_modules" ]; then
step "Installing v2 dependencies (pnpm install --frozen-lockfile)…"
if ! pnpm install --frozen-lockfile 2>&1 | sed 's/^/ /'; then
die "pnpm install failed in $V2_ROOT." "See output above."
fi
ok "Dependencies installed."
else
ok "Dependencies already installed."
fi
# ─── hand off to the TS driver ──────────────────────────────────────────
printf '\n %s\n\n' "$(dim 'Handing off to the migration driver…')"
# exec so Ctrl-C propagates directly to the driver and we don't waste a
# PID just holding the slot.
exec pnpm --silent run migrate:v1-to-v2 -- --v1-root "$V1_ROOT"
+1
View File
@@ -16,6 +16,7 @@
"prepare": "husky",
"setup": "tsx setup/index.ts",
"setup:auto": "tsx setup/auto.ts",
"migrate:v1-to-v2": "tsx setup/migrate.ts",
"chat": "tsx scripts/chat.ts",
"auth": "tsx src/whatsapp-auth.ts",
"lint": "eslint src/",
+594
View File
@@ -0,0 +1,594 @@
/**
* v1 → v2 migration sequencer — `pnpm run migrate:v1-to-v2`.
*
* Runs in a v2 worktree. Reads v1 state from `--v1-root <path>` (defaults
* to the parent directory when run from a worktree named `.migrate-worktree`),
* extracts it into `.nanoclaw-migrations/v1-data/` in the v1 tree, seeds
* v2 central state into the current worktree's `data/v2.db`, and leaves
* the swap for the user to run after validation.
*
* Responsibility split mirrors `setup/auto.ts`:
* - This file: step sequencing, clack UI, decision routing.
* - Primitives: runner (spinner + log + fail), claude-assist (failure
* recovery), claude-handoff (interactive recovery for ambiguous
* customizations).
* - Library: `setup/migrate/*.ts` — detect, extract, seed, owner inference,
* guide composition.
*
* Env knobs:
* NANOCLAW_V1_ROOT v1 install path; defaults to `..`
* NANOCLAW_MIGRATE_SKIP comma-separated step names to skip
* (preflight|safety|extract|owner|guide|seed|copy|rebuild|verify)
* NANOCLAW_SKIP_CLAUDE_ASSIST=1 disables the claude-assist offer on failure
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { offerClaudeAssist } from './lib/claude-assist.js';
import { offerClaudeHandoff } from './lib/claude-handoff.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail } from './lib/runner.js';
import { brandBold, dimWrap } from './lib/theme.js';
import { detectInstall } from './migrate/detect-v1.js';
import { runExtract, type V1ExtractResult } from './migrate/extract-v1.js';
import { writeGuide } from './migrate/guide-compose.js';
import { runSeed, type SeedStats } from './migrate/seed-v2.js';
const RUN_START = Date.now();
async function main(): Promise<void> {
p.intro(`${brandBold('NanoClaw')} · v1 → v2 migration`);
setupLog.reset({
mode: 'migrate',
cwd: process.cwd(),
node: process.version,
started: new Date(RUN_START).toISOString(),
});
const v1Root = resolveV1Root();
const v2Root = process.cwd();
const skip = new Set(
(process.env.NANOCLAW_MIGRATE_SKIP ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
p.log.info(
dimWrap(
`v1 install: ${v1Root}\nv2 worktree: ${v2Root}`,
4,
),
);
// ── 1. Preflight ─────────────────────────────────────────────
if (!skip.has('preflight')) await stepPreflight(v1Root, v2Root);
// ── 2. Extract ───────────────────────────────────────────────
const extract = skip.has('extract') ? await rehydrateExtract(v1Root) : await stepExtract(v1Root);
// ── 3. Owner confirmation ─────────────────────────────────────
if (!skip.has('owner')) await stepOwner(extract);
// ── 4. Guide ─────────────────────────────────────────────────
if (!skip.has('guide')) await stepGuide(extract);
// ── 5. Safety net ────────────────────────────────────────────
if (!skip.has('safety')) await stepSafetyNet(v1Root);
// ── 6. Seed ──────────────────────────────────────────────────
const seedStats = skip.has('seed') ? null : await stepSeed(v1Root, v2Root);
// ── 7. Copy-over (CLAUDE.local.md, user skills, env) ─────────
if (!skip.has('copy')) await stepCopyOver(extract, v2Root);
// ── 8. Rebuild (Claude handoff, opt-in) ──────────────────────
if (!skip.has('rebuild')) await stepRebuild(extract);
// ── 9. Verify ────────────────────────────────────────────────
if (!skip.has('verify')) await stepVerify(v2Root, seedStats);
// ── 10. Final outro — swap is left to the operator ───────────
outroSwapInstructions(v1Root, v2Root);
setupLog.complete(Date.now() - RUN_START);
}
// ── Steps ──
async function stepPreflight(v1Root: string, v2Root: string): Promise<void> {
const s = p.spinner();
s.start('Checking v1 install and v2 worktree…');
const v1Verdict = detectInstall(v1Root);
const v2Verdict = detectInstall(v2Root);
s.stop('State scanned.');
if (v1Verdict.kind === 'fresh') {
await fail(
'preflight',
`No NanoClaw install detected at ${v1Root}.`,
`Point --v1-root at your v1 checkout, or set NANOCLAW_V1_ROOT.`,
);
}
if (v1Verdict.kind === 'v2') {
await fail(
'preflight',
`Install at ${v1Root} is already v2 — nothing to migrate.`,
`If you want to re-seed, delete data/v2.db and re-run.`,
);
}
if (v1Verdict.kind === 'mixed') {
p.log.warn(
`Install at ${v1Root} has both v1 and v2 DBs. Re-running seed is safe (idempotent), ` +
`but verify you're not about to overwrite partial progress.`,
);
}
if (v2Verdict.kind !== 'v2' && v2Verdict.kind !== 'fresh') {
await fail(
'preflight',
`Worktree at ${v2Root} doesn't look like v2.`,
`Create one via: git worktree add .migrate-worktree origin/v2 --detach`,
);
}
// Dirty-tree check on the v1 install.
const dirty = sh('git status --porcelain', v1Root);
if (dirty.trim().length > 0) {
p.log.warn(`v1 install has uncommitted changes:\n${k.dim(dirty)}`);
const proceed = ensureAnswer(
await p.confirm({
message: 'Proceed anyway? (the migration guide will reference your v1 HEAD, uncommitted changes are not captured)',
initialValue: false,
}),
);
if (!proceed) await fail('preflight', 'Cancelled — clean the v1 tree and re-run.');
}
setupLog.step('preflight', 'success', 0, {
V1_KIND: v1Verdict.kind,
V1_VERSION: v1Verdict.packageVersion ?? '(unknown)',
V2_KIND: v2Verdict.kind,
});
p.log.success('Preflight looks good.');
}
async function stepExtract(v1Root: string): Promise<V1ExtractResult> {
const s = p.spinner();
s.start('Reading v1 state…');
const start = Date.now();
let result: V1ExtractResult;
try {
result = await runExtract(v1Root);
} catch (err) {
s.stop('Extract failed.');
await fail('extract', `Could not read v1 state: ${(err as Error).message}`, undefined);
throw err; // unreachable
}
const ms = Date.now() - start;
s.stop(`v1 state read in ${Math.round(ms / 1000)}s.`);
const summary = [
`Registered groups: ${result.registeredGroups.length}`,
`Sessions: ${result.sessions.length}`,
`Scheduled tasks: ${result.scheduledTasks.length}`,
`Channels in use: ${result.channelsInUse.join(', ') || '(none)'}`,
`Unknown JIDs: ${result.unknownJids.length}`,
`Applied skills: ${result.appliedSkillMerges.length}`,
`Customized files: ${result.customizedFiles.length}`,
].join('\n');
p.note(summary, 'v1 state');
if (result.unknownJids.length > 0) {
p.log.warn(
`Unrecognized JID formats — edit \`${result.outDir}/registered-groups.json\` to set ` +
`\`inferred_channel_type\` before seeding:\n` +
result.unknownJids.map((j) => ` ${j}`).join('\n'),
);
}
setupLog.step('extract', 'success', ms, {
GROUPS: String(result.registeredGroups.length),
CHANNELS: result.channelsInUse.join(',') || '(none)',
UNKNOWN_JIDS: String(result.unknownJids.length),
});
return result;
}
async function rehydrateExtract(v1Root: string): Promise<V1ExtractResult> {
const v1Data = path.join(v1Root, '.nanoclaw-migrations', 'v1-data');
if (!fs.existsSync(v1Data)) {
await fail(
'extract',
`Skipping extract but no prior state at ${v1Data}.`,
`Re-run without NANOCLAW_MIGRATE_SKIP=extract, or run extract manually first.`,
);
}
// Re-read from disk to rebuild the result. The cheapest way is to re-run
// extract in dry-identity mode — it's idempotent and fast enough.
return runExtract(v1Root);
}
async function stepOwner(ex: V1ExtractResult): Promise<void> {
const proposal = ex.ownerProposal;
if (proposal.userId && proposal.confidence === 'high') {
p.log.success(`Owner: ${k.cyan(proposal.userId)} (${proposal.source})`);
return;
}
if (proposal.userId) {
const keep = ensureAnswer(
await p.confirm({
message: `I think the owner is ${k.cyan(proposal.userId)} (from ${proposal.source}). Correct?`,
initialValue: true,
}),
);
if (keep) {
setupLog.step('owner', 'success', 0, { USER_ID: proposal.userId, SOURCE: proposal.source });
return;
}
}
p.log.info(
dimWrap(
'I need an explicit owner user_id. Format is `<channel>:<handle>` — for example `phone:+15551234567`, ' +
'`discord:123456789012345678`, `telegram:987654321`. Type `?` to hand off to Claude if you need help finding it.',
4,
),
);
const answer = await p.text({
message: 'Owner user_id:',
placeholder: 'phone:+15551234567',
});
const raw = ensureAnswer(answer);
if (raw === '?' ) {
await offerClaudeHandoff({
channel: 'migrate',
step: 'owner',
stepDescription: 'The migration needs to identify the operator (owner) for v2',
files: [path.join(ex.outDir, 'registered-groups.json'), path.join(ex.outDir, 'sender-allowlist.json')],
});
// After handoff, prompt again — the user may have edited owner.json directly.
const ownerJson = path.join(ex.outDir, 'owner.json');
try {
const edited = JSON.parse(fs.readFileSync(ownerJson, 'utf-8')) as { userId?: string };
if (edited.userId) {
ex.ownerProposal = { userId: edited.userId, source: 'claude-handoff', confidence: 'high' };
rewriteOwnerFile(ex);
p.log.success(`Owner: ${k.cyan(edited.userId)} (claude-handoff)`);
setupLog.step('owner', 'success', 0, { USER_ID: edited.userId, SOURCE: 'claude-handoff' });
return;
}
} catch {
/* fall through to re-prompt */
}
return stepOwner(ex);
}
if (!raw.includes(':')) {
p.log.error('Expected format `<channel>:<handle>` — try again.');
return stepOwner(ex);
}
ex.ownerProposal = { userId: raw, source: 'user-entered', confidence: 'high' };
rewriteOwnerFile(ex);
setupLog.step('owner', 'success', 0, { USER_ID: raw, SOURCE: 'user-entered' });
}
async function stepGuide(ex: V1ExtractResult): Promise<void> {
const s = p.spinner();
s.start('Writing migration guide…');
let guidePath: string;
try {
guidePath = writeGuide(ex);
} catch (err) {
s.stop('Guide write failed.');
await fail('guide', `Could not write guide: ${(err as Error).message}`);
throw err;
}
s.stop('Migration guide written.');
p.log.info(k.dim(guidePath));
setupLog.step('guide', 'success', 0, { GUIDE_PATH: guidePath });
const review = ensureAnswer(
await p.confirm({
message: 'Open the guide now and review before seeding?',
initialValue: false,
}),
);
if (review) {
p.log.info(
dimWrap(`Review the guide at ${guidePath}, then come back and press Enter to continue.`, 4),
);
await p.text({ message: 'Press Enter to continue' });
}
}
async function stepSafetyNet(v1Root: string): Promise<void> {
const s = p.spinner();
s.start('Creating rollback point…');
const hash = sh('git rev-parse --short HEAD', v1Root) || 'nohash';
const ts = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const tagName = `pre-v2-${hash}-${ts}`;
const branchName = `backup/pre-v2-${hash}-${ts}`;
const tag = shSafe(`git tag ${tagName}`, v1Root);
const branch = shSafe(`git branch ${branchName}`, v1Root);
s.stop(`Rollback point created: tag \`${tagName}\``);
setupLog.step('safety', 'success', 0, { TAG: tagName, BRANCH: branchName });
p.log.info(
dimWrap(
`Rollback with: git reset --hard ${tagName}\n(+ restore data dirs after swap — see guide's Rollback section)`,
4,
),
);
void tag;
void branch;
}
async function stepSeed(v1Root: string, v2Root: string): Promise<SeedStats> {
const s = p.spinner();
s.start('Seeding v2 central DB…');
let stats: SeedStats;
try {
stats = runSeed({ v1Root, v2DbPath: path.join(v2Root, 'data', 'v2.db') });
} catch (err) {
s.stop('Seed failed.');
const msg = (err as Error).message;
p.log.error(msg);
const tryAgain = await offerClaudeAssist({
stepName: 'migrate-seed',
msg,
hint: `v1 root: ${v1Root}. Check src/channels/ for the adapters the seeder expected.`,
rawLogPath: setupLog.stepRawLog('migrate-seed'),
});
if (tryAgain) {
p.log.info('Re-running seed after Claude-assisted fix…');
return stepSeed(v1Root, v2Root);
}
await fail('seed', msg);
throw err;
}
s.stop('v2 central DB seeded.');
const rows: [string, string][] = [
['Agent groups:', `inserted ${stats.agentGroups.inserted} · skipped ${stats.agentGroups.skipped}`],
['Messaging groups:', `inserted ${stats.messagingGroups.inserted} · skipped ${stats.messagingGroups.skipped}`],
['Wirings:', `inserted ${stats.wirings.inserted} · skipped ${stats.wirings.skipped}`],
['Users:', `inserted ${stats.users.inserted}`],
['Roles:', `inserted ${stats.roles.inserted} · skipped ${stats.roles.skipped}`],
['Memberships:', `inserted ${stats.members.inserted}`],
['DM cache:', `inserted ${stats.userDms.inserted}`],
['container.json:', `written ${stats.containerConfigs.written}`],
];
const labelW = Math.max(...rows.map(([l]) => l.length));
p.note(rows.map(([l, v]) => `${k.cyan(l.padEnd(labelW))} ${v}`).join('\n'), 'Seed results');
for (const w of stats.warnings) p.log.warn(w);
setupLog.step('seed', 'success', 0, {
AG_NEW: String(stats.agentGroups.inserted),
MG_NEW: String(stats.messagingGroups.inserted),
WIRING_NEW: String(stats.wirings.inserted),
OWNER_DM: String(stats.userDms.inserted),
WARNINGS: String(stats.warnings.length),
});
return stats;
}
async function stepCopyOver(ex: V1ExtractResult, v2Root: string): Promise<void> {
const s = p.spinner();
s.start('Copying over CLAUDE.md → CLAUDE.local.md + user skills…');
const details: string[] = [];
// groups/<folder>/CLAUDE.md → v2 groups/<folder>/CLAUDE.local.md
for (const g of ex.groups) {
if (!g.has_claude_md) continue;
const src = path.join(ex.v1Root, 'groups', g.folder, 'CLAUDE.md');
const dstDir = path.join(v2Root, 'groups', g.folder);
const dst = path.join(dstDir, 'CLAUDE.local.md');
if (fs.existsSync(dst)) {
details.push(`skip ${g.folder} (CLAUDE.local.md already exists)`);
continue;
}
fs.mkdirSync(dstDir, { recursive: true });
fs.copyFileSync(src, dst);
details.push(`copied ${g.folder}/CLAUDE.md → CLAUDE.local.md`);
}
// User-authored skills — copy if not already present on v2.
for (const dir of ex.userAuthoredSkillDirs) {
const src = path.join(ex.v1Root, '.claude', 'skills', dir);
const dst = path.join(v2Root, '.claude', 'skills', dir);
if (!fs.existsSync(src)) continue;
if (fs.existsSync(dst)) {
details.push(`skip skill ${dir} (already present)`);
continue;
}
fs.cpSync(src, dst, { recursive: true });
details.push(`copied skill ${dir}`);
}
// Non-secret .env merge (additive — never overwrite existing keys in v2's .env).
const v2Env = path.join(v2Root, '.env');
const existing = fs.existsSync(v2Env) ? fs.readFileSync(v2Env, 'utf-8') : '';
const existingKeys = new Set<string>();
for (const line of existing.split('\n')) {
const eq = line.indexOf('=');
if (eq > 0) existingKeys.add(line.slice(0, eq).trim());
}
const additions: string[] = [];
for (const [k, v] of Object.entries(ex.env)) {
if (existingKeys.has(k)) continue;
additions.push(`${k}=${v}`);
}
if (additions.length > 0) {
const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
fs.appendFileSync(v2Env, prefix + additions.join('\n') + '\n');
details.push(`appended ${additions.length} env key(s)`);
}
// NANOCLAW_ADMIN_USER_IDS — owner gets admin-level approval capability.
if (ex.ownerProposal.userId && !existingKeys.has('NANOCLAW_ADMIN_USER_IDS')) {
fs.appendFileSync(v2Env, `NANOCLAW_ADMIN_USER_IDS=${ex.ownerProposal.userId}\n`);
details.push('appended NANOCLAW_ADMIN_USER_IDS');
}
s.stop(`Copy-over complete (${details.length} action${details.length === 1 ? '' : 's'}).`);
if (details.length > 0) p.log.info(k.dim(details.join('\n')));
setupLog.step('copy', 'success', 0, { ACTIONS: String(details.length) });
}
async function stepRebuild(ex: V1ExtractResult): Promise<void> {
if (ex.customizedFiles.length === 0) {
p.log.info('No code customizations detected — nothing to rebuild.');
return;
}
p.log.info(
dimWrap(
`You have ${ex.customizedFiles.length} customized file(s) from v1. Most target paths that don't exist on v2 ` +
`(monolithic src/db.ts, IPC, credential-proxy, etc.). Re-expressing them against v2's module system works best ` +
`interactively with Claude.`,
4,
),
);
const want = ensureAnswer(
await p.confirm({
message: 'Hand off to Claude now to walk through the rebuild?',
initialValue: true,
}),
);
if (!want) {
p.log.info(
dimWrap(
`You can do this later — re-run with NANOCLAW_MIGRATE_SKIP set to everything else, or ` +
`open the guide and feed it to Claude yourself.`,
4,
),
);
setupLog.step('rebuild', 'skipped', 0, {});
return;
}
await offerClaudeHandoff({
channel: 'migrate',
step: 'rebuild',
stepDescription: 'Reapply v1 source customizations on v2 using the migration guide',
completedSteps: ['preflight', 'extract', 'owner', 'guide', 'safety-net', 'seed', 'copy-over'],
files: [
path.join(ex.outDir, '..', 'guide.md'),
path.join(ex.outDir, 'git-customizations.json'),
'docs/module-contract.md',
'docs/architecture.md',
],
});
setupLog.step('rebuild', 'success', 0, {});
}
async function stepVerify(v2Root: string, seed: SeedStats | null): Promise<void> {
const s = p.spinner();
s.start('Running build + tests in v2 worktree…');
const build = spawnSync('pnpm', ['run', 'build'], { cwd: v2Root, stdio: 'pipe' });
const testRun = spawnSync('pnpm', ['test'], { cwd: v2Root, stdio: 'pipe' });
const buildOk = build.status === 0;
const testsOk = testRun.status === 0;
s.stop(buildOk && testsOk ? 'Build + tests passed.' : 'Build/tests had issues.');
const warnings: string[] = [];
if (!buildOk) warnings.push(`pnpm run build exited ${build.status}`);
if (!testsOk) warnings.push(`pnpm test exited ${testRun.status}`);
if (seed) warnings.push(...seed.warnings);
if (warnings.length > 0) {
for (const w of warnings) p.log.warn(w);
await offerClaudeAssist({
stepName: 'migrate-verify',
msg: 'Verification completed with issues.',
hint: warnings.join(' · '),
});
}
setupLog.step('verify', buildOk && testsOk ? 'success' : 'failed', 0, {
BUILD: buildOk ? 'ok' : 'failed',
TESTS: testsOk ? 'ok' : 'failed',
WARNINGS: String(warnings.length),
});
}
// ── Outro ──
function outroSwapInstructions(v1Root: string, v2Root: string): void {
const lines = [
`The v2 worktree (${v2Root}) now has:`,
` • data/v2.db seeded from v1`,
` • groups/<folder>/CLAUDE.local.md carried over`,
` • .env merged (owner + non-secret keys)`,
'',
`Next: run /init-onecli to migrate credentials, install channel skills (/add-<name>),`,
`build the container (./container/build.sh), and live-smoke-test from the worktree.`,
`When satisfied, swap the worktree into the v1 tree — see the guide's Rollback section for the exact commands.`,
];
p.note(lines.join('\n'), 'What to do next');
p.outro(k.green('Migration seed complete.'));
void v1Root;
}
// ── Helpers ──
function resolveV1Root(): string {
const explicit = process.env.NANOCLAW_V1_ROOT;
const cliArg = process.argv.indexOf('--v1-root');
if (cliArg !== -1 && process.argv[cliArg + 1]) {
return path.resolve(process.argv[cliArg + 1]);
}
if (explicit) return path.resolve(explicit);
// Default: parent dir if we're in a worktree named .migrate-worktree.
const here = path.basename(process.cwd());
if (here === '.migrate-worktree' || here.startsWith('.migrate-')) {
return path.resolve(process.cwd(), '..');
}
// Otherwise, treat cwd as both roots — useful when the user has the v2
// branch checked out in-place (advanced case; we warn about it in preflight).
return process.cwd();
}
function sh(cmd: string, cwd: string): string {
try {
return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch {
return '';
}
}
function shSafe(cmd: string, cwd: string): boolean {
try {
execSync(cmd, { cwd, stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function rewriteOwnerFile(ex: V1ExtractResult): void {
const ownerJson = path.join(ex.outDir, 'owner.json');
fs.writeFileSync(ownerJson, JSON.stringify(ex.ownerProposal, null, 2) + '\n');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Detect whether a given directory is a NanoClaw v1 install, v2 install,
* mixed (half-migrated), or fresh (nothing yet).
*
* Read-only. No mutation.
*/
import fs from 'fs';
import path from 'path';
export type InstallKind = 'v1' | 'v2' | 'mixed' | 'fresh';
export interface Verdict {
kind: InstallKind;
v1DbPath: string | null; // store/messages.db — present iff v1 or mixed
v2DbPath: string | null; // data/v2.db — present iff v2 or mixed
hasPnpmManaged: boolean; // packageManager key in package.json
packageVersion: string | null;
reasons: string[]; // human-readable narrative for the verdict
}
export function detectInstall(projectRoot: string): Verdict {
const reasons: string[] = [];
const v1Db = path.join(projectRoot, 'store', 'messages.db');
const v2Db = path.join(projectRoot, 'data', 'v2.db');
const pkg = path.join(projectRoot, 'package.json');
const v1 = fs.existsSync(v1Db);
const v2 = fs.existsSync(v2Db);
if (v1) reasons.push(`v1 DB present at store/messages.db`);
if (v2) reasons.push(`v2 DB present at data/v2.db`);
let hasPnpmManaged = false;
let packageVersion: string | null = null;
if (fs.existsSync(pkg)) {
try {
const json = JSON.parse(fs.readFileSync(pkg, 'utf-8')) as {
version?: string;
packageManager?: string;
};
hasPnpmManaged = !!json.packageManager?.startsWith('pnpm@');
packageVersion = json.version ?? null;
if (packageVersion) reasons.push(`package.json version=${packageVersion}`);
if (hasPnpmManaged) reasons.push(`packageManager=${json.packageManager}`);
} catch {
/* malformed — not load-bearing */
}
}
let kind: InstallKind;
if (v1 && v2) kind = 'mixed';
else if (v1) kind = 'v1';
else if (v2) kind = 'v2';
else kind = 'fresh';
return {
kind,
v1DbPath: v1 ? v1Db : null,
v2DbPath: v2 ? v2Db : null,
hasPnpmManaged,
packageVersion,
reasons,
};
}
+466
View File
@@ -0,0 +1,466 @@
/**
* Extract NanoClaw v1 state into portable JSON.
*
* Runs from a v2 worktree against an arbitrary v1 project root. Writes the
* extracted state under `<v1Root>/.nanoclaw-migrations/v1-data/` so the
* data travels with the v1 checkout across the worktree swap.
*
* This is a library function — invoked from `setup/migrate.ts`, not a
* standalone CLI. Emits progress via the returned result, not via stdout.
*
* Read-only. Never writes into v1 tables; never reads secrets.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { CHANNEL_INSTALL_SKILL, inferChannelTypeFromJid } from './jid.js';
import { proposeOwner, type OwnerProposal, type RegisteredGroupLite, type V1Allowlist } from './owner-propose.js';
// Allowlist of non-secret .env keys. Anything else is dropped on the floor —
// secrets never leave the extraction step.
const SAFE_ENV_KEYS = new Set([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'TZ',
'CONTAINER_IMAGE',
'CONTAINER_TIMEOUT',
'CONTAINER_MAX_OUTPUT_SIZE',
'IDLE_TIMEOUT',
'MAX_CONCURRENT_CONTAINERS',
'POLL_INTERVAL',
'SCHEDULER_POLL_INTERVAL',
// Owner hints (not secrets — channel handles)
'OWNER_JID',
'OWNER_PHONE',
'OWNER_USER_ID',
'NANOCLAW_ADMIN_USER_IDS',
]);
// Known upstream skill branches (v1 era). Used to classify skill merges so
// the guide can list the matching v2 install commands.
const KNOWN_SKILL_BRANCHES = new Set([
'skill/whatsapp',
'skill/telegram',
'skill/discord',
'skill/slack',
'skill/imessage',
'skill/webex',
'skill/matrix',
'skill/github',
'skill/linear',
'skill/teams',
'skill/gchat',
'skill/wechat',
'skill/resend',
'skill/whatsapp-cloud',
'skill/voice-transcription',
'skill/image-vision',
'skill/pdf-reader',
'skill/reactions',
'skill/compact',
'skill/apple-container',
'skill/dashboard',
'skill/vercel',
'skill/ollama-tool',
'skill/parallel',
]);
const SKILL_BRANCH_TO_V2: Record<string, string> = {
'skill/whatsapp': '/add-whatsapp',
'skill/telegram': '/add-telegram',
'skill/discord': '/add-discord',
'skill/slack': '/add-slack',
'skill/imessage': '/add-imessage',
'skill/webex': '/add-webex',
'skill/matrix': '/add-matrix',
'skill/github': '/add-github',
'skill/linear': '/add-linear',
'skill/teams': '/add-teams',
'skill/gchat': '/add-gchat',
'skill/wechat': '/add-wechat',
'skill/resend': '/add-resend',
'skill/whatsapp-cloud': '/add-whatsapp-cloud',
'skill/voice-transcription': '/add-voice-transcription',
'skill/image-vision': '/add-image-vision',
'skill/pdf-reader': '/add-pdf-reader',
'skill/reactions': '/add-reactions',
'skill/compact': '/add-compact',
'skill/apple-container': '/convert-to-apple-container',
'skill/dashboard': '/add-dashboard',
'skill/vercel': '/add-vercel',
'skill/ollama-tool': '/add-ollama-tool',
'skill/parallel': '/add-parallel',
};
export interface V1Group {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
container_config: unknown | null;
requires_trigger: number;
is_main: boolean;
inferred_channel_type: string;
inferred_is_group: number;
}
export interface V1ExtractResult {
v1Root: string;
outDir: string;
env: Record<string, string>;
registeredGroups: V1Group[];
sessions: Array<{ group_folder: string; session_id: string }>;
scheduledTasks: unknown[];
routerState: Record<string, string>;
groups: Array<{ folder: string; has_claude_md: boolean; claude_md_bytes: number; files: string[] }>;
senderAllowlist: V1Allowlist | null;
mountAllowlist: unknown | null;
gitHead: string;
gitMergeBase: string | null;
gitUpstreamRef: string | null;
appliedSkillMerges: Array<{ branch: string; merge_commit: string; v2_install?: string }>;
userAuthoredSkillDirs: string[];
customizedFiles: Array<{ path: string; additions: number; deletions: number }>;
channelsInUse: string[];
unknownJids: string[];
chatRowCount: number;
ownerProposal: OwnerProposal;
requiredChannelSkills: string[];
}
export async function runExtract(v1Root: string): Promise<V1ExtractResult> {
const outDir = path.join(v1Root, '.nanoclaw-migrations', 'v1-data');
fs.mkdirSync(outDir, { recursive: true });
const env = readSafeEnv(path.join(v1Root, '.env'));
const senderAllowlist = readJson<V1Allowlist>(
path.join(os.homedir(), '.config', 'nanoclaw', 'sender-allowlist.json'),
);
const mountAllowlist = readJson<unknown>(
path.join(os.homedir(), '.config', 'nanoclaw', 'mount-allowlist.json'),
);
const db = extractDb(path.join(v1Root, 'store', 'messages.db'));
const git = extractGit(v1Root);
const groups = extractGroupsDir(path.join(v1Root, 'groups'));
const channelsInUse = [...new Set(db.registeredGroups.map((g) => g.inferred_channel_type))].filter(
(c) => c !== 'unknown',
);
const requiredChannelSkills = [
...new Set(channelsInUse.map((c) => CHANNEL_INSTALL_SKILL[c] ?? `/add-${c}`)),
];
const ownerProposal = proposeOwner(
env,
db.registeredGroups as RegisteredGroupLite[],
senderAllowlist,
);
const result: V1ExtractResult = {
v1Root,
outDir,
env,
registeredGroups: db.registeredGroups,
sessions: db.sessions,
scheduledTasks: db.scheduledTasks,
routerState: db.routerState,
groups,
senderAllowlist,
mountAllowlist,
gitHead: git.head,
gitMergeBase: git.mergeBase,
gitUpstreamRef: git.upstreamRef,
appliedSkillMerges: git.appliedSkillMerges,
userAuthoredSkillDirs: git.userSkillDirs,
customizedFiles: git.customizedFiles,
channelsInUse,
unknownJids: db.unknownJids,
chatRowCount: db.chatRowCount,
ownerProposal,
requiredChannelSkills,
};
writeAllArtifacts(result);
return result;
}
// ── v1 DB reader ──
interface DbExtract {
registeredGroups: V1Group[];
sessions: Array<{ group_folder: string; session_id: string }>;
scheduledTasks: unknown[];
routerState: Record<string, string>;
unknownJids: string[];
chatRowCount: number;
}
function extractDb(dbPath: string): DbExtract {
const empty: DbExtract = {
registeredGroups: [],
sessions: [],
scheduledTasks: [],
routerState: {},
unknownJids: [],
chatRowCount: 0,
};
if (!fs.existsSync(dbPath)) return empty;
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
db.pragma('query_only = ON');
const result: DbExtract = { ...empty };
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
container_config: string | null;
requires_trigger: number | null;
is_main: number | null;
}>;
for (const row of rows) {
const inferred = inferChannelTypeFromJid(row.jid);
if (inferred.channel_type === 'unknown') result.unknownJids.push(row.jid);
result.registeredGroups.push({
jid: row.jid,
name: row.name,
folder: row.folder,
trigger_pattern: row.trigger_pattern,
added_at: row.added_at,
container_config: row.container_config ? safeJsonParse(row.container_config) : null,
requires_trigger: row.requires_trigger ?? 1,
is_main: row.is_main === 1,
inferred_channel_type: inferred.channel_type,
inferred_is_group: inferred.is_group,
});
}
try {
result.sessions = db.prepare('SELECT group_folder, session_id FROM sessions').all() as Array<{
group_folder: string;
session_id: string;
}>;
} catch {
/* pre-1.2 DBs may not have this table */
}
try {
result.scheduledTasks = db
.prepare(
'SELECT id, group_folder, chat_jid, prompt, schedule_type, schedule_value, ' +
"COALESCE(context_mode, 'isolated') AS context_mode, status, created_at " +
"FROM scheduled_tasks WHERE status IN ('active', 'paused')",
)
.all();
} catch {
/* older DBs may not have scheduled_tasks */
}
try {
const rs = db.prepare('SELECT key, value FROM router_state').all() as Array<{
key: string;
value: string;
}>;
for (const r of rs) result.routerState[r.key] = r.value;
} catch {
/* optional */
}
try {
const c = db.prepare('SELECT COUNT(*) AS c FROM chats').get() as { c: number };
result.chatRowCount = c.c;
} catch {
/* optional */
}
db.close();
return result;
}
// ── git state ──
interface GitState {
head: string;
mergeBase: string | null;
upstreamRef: string | null;
appliedSkillMerges: Array<{ branch: string; merge_commit: string; v2_install?: string }>;
userSkillDirs: string[];
customizedFiles: Array<{ path: string; additions: number; deletions: number }>;
}
function extractGit(v1Root: string): GitState {
const head = sh('git rev-parse HEAD', v1Root);
const { base, upstream } = findMergeBase(v1Root);
const appliedSkillMerges: GitState['appliedSkillMerges'] = [];
if (head) {
const merges = sh(
`git log --merges --pretty=format:"%H%x09%s" ${base ? `${base}..HEAD` : ''}`,
v1Root,
);
for (const line of merges.split('\n').filter(Boolean)) {
const [hash, ...rest] = line.split('\t');
const subject = rest.join('\t');
for (const branch of KNOWN_SKILL_BRANCHES) {
const slug = branch.replace('skill/', '');
if (subject.includes(branch) || subject.includes(slug)) {
appliedSkillMerges.push({
branch,
merge_commit: hash,
v2_install: SKILL_BRANCH_TO_V2[branch],
});
break;
}
}
}
}
const customizedFiles: GitState['customizedFiles'] = [];
if (base) {
const numstat = sh(`git diff --numstat ${base}..HEAD`, v1Root);
for (const line of numstat.split('\n').filter(Boolean)) {
const [addStr, delStr, file] = line.split('\t');
if (!file) continue;
customizedFiles.push({
path: file,
additions: addStr === '-' ? 0 : parseInt(addStr, 10) || 0,
deletions: delStr === '-' ? 0 : parseInt(delStr, 10) || 0,
});
}
}
const userSkillDirs: string[] = [];
const claudeSkills = path.join(v1Root, '.claude', 'skills');
if (fs.existsSync(claudeSkills)) {
for (const entry of fs.readdirSync(claudeSkills)) {
const full = path.join(claudeSkills, entry);
if (!fs.statSync(full).isDirectory()) continue;
const added = sh(
`git log --diff-filter=A --pretty=format:"%H" -- .claude/skills/${entry}/SKILL.md`,
v1Root,
);
const addedHash = added.split('\n')[0];
const fromSkillMerge = appliedSkillMerges.some((m) => m.merge_commit === addedHash);
if (!fromSkillMerge) userSkillDirs.push(entry);
}
}
return { head, mergeBase: base, upstreamRef: upstream, appliedSkillMerges, userSkillDirs, customizedFiles };
}
function findMergeBase(cwd: string): { base: string | null; upstream: string | null } {
for (const remote of ['upstream', 'origin']) {
for (const branch of ['main', 'master']) {
const ref = `${remote}/${branch}`;
if (sh(`git rev-parse --verify --quiet ${ref}`, cwd)) {
const base = sh(`git merge-base HEAD ${ref}`, cwd);
if (base) return { base, upstream: ref };
}
}
}
return { base: null, upstream: null };
}
// ── groups dir ──
function extractGroupsDir(
groupsDir: string,
): Array<{ folder: string; has_claude_md: boolean; claude_md_bytes: number; files: string[] }> {
if (!fs.existsSync(groupsDir)) return [];
const out: Array<{ folder: string; has_claude_md: boolean; claude_md_bytes: number; files: string[] }> = [];
for (const entry of fs.readdirSync(groupsDir)) {
const full = path.join(groupsDir, entry);
if (!fs.statSync(full).isDirectory()) continue;
const claudeMd = path.join(full, 'CLAUDE.md');
const hasClaude = fs.existsSync(claudeMd);
out.push({
folder: entry,
has_claude_md: hasClaude,
claude_md_bytes: hasClaude ? fs.statSync(claudeMd).size : 0,
files: fs.readdirSync(full),
});
}
return out;
}
// ── helpers ──
function readSafeEnv(envPath: string): Record<string, string> {
if (!fs.existsSync(envPath)) return {};
const out: Record<string, string> = {};
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
if (!SAFE_ENV_KEYS.has(key)) continue;
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (value) out[key] = value;
}
return out;
}
function readJson<T>(p: string): T | null {
if (!fs.existsSync(p)) return null;
try {
return JSON.parse(fs.readFileSync(p, 'utf-8')) as T;
} catch {
return null;
}
}
function safeJsonParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}
function sh(cmd: string, cwd: string): string {
try {
return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch {
return '';
}
}
// ── write artifacts ──
function writeAllArtifacts(r: V1ExtractResult): void {
const write = (name: string, data: unknown) =>
fs.writeFileSync(path.join(r.outDir, name), JSON.stringify(data, null, 2) + '\n');
write('env.json', r.env);
write('sender-allowlist.json', r.senderAllowlist ?? {});
write('mount-allowlist.json', r.mountAllowlist ?? {});
write('registered-groups.json', r.registeredGroups);
write('sessions.json', r.sessions);
write('scheduled-tasks.json', r.scheduledTasks);
write('router-state.json', r.routerState);
write('groups.json', r.groups);
write('applied-skills.json', {
merges: r.appliedSkillMerges,
user_authored_skill_dirs: r.userAuthoredSkillDirs,
});
write('git-customizations.json', {
head: r.gitHead,
merge_base: r.gitMergeBase,
upstream_ref: r.gitUpstreamRef,
files: r.customizedFiles,
});
write('owner.json', r.ownerProposal);
}
+197
View File
@@ -0,0 +1,197 @@
/**
* Compose `.nanoclaw-migrations/guide.md` from extracted v1 state.
*
* The guide is the durable human + Claude-readable record of the migration
* plan. The sequencer can write this between extract and worktree so the
* user has a checkpoint before any v2 state is touched.
*/
import fs from 'fs';
import path from 'path';
import type { V1ExtractResult } from './extract-v1.js';
export function composeGuide(ex: V1ExtractResult): string {
const owner = ex.ownerProposal.userId ?? '(unresolved — will prompt)';
const now = new Date().toISOString();
const sections: string[] = [
`# NanoClaw v1→v2 Migration Guide`,
``,
`Generated: ${now}`,
`v1 root: \`${ex.v1Root}\``,
`v1 HEAD: \`${ex.gitHead || 'unknown'}\``,
`Owner: \`${owner}\` (confidence: ${ex.ownerProposal.confidence}, source: ${ex.ownerProposal.source})`,
``,
`---`,
``,
`## Seed plan`,
``,
seedPlanTables(ex),
``,
`## Skills to install (in order)`,
``,
skillsToInstall(ex),
``,
`## Reapply-as-is`,
``,
`- Non-secret \`.env\` keys (already captured in \`v1-data/env.json\`)`,
`- \`groups/<folder>/CLAUDE.md\` → v2 \`groups/<folder>/CLAUDE.local.md\` (v2 regenerates \`CLAUDE.md\` at spawn; per-group agent memory lives in \`.local.md\`)`,
`- User-authored skills under \`.claude/skills/\`: ${ex.userAuthoredSkillDirs.length > 0 ? ex.userAuthoredSkillDirs.map((d) => `\`${d}\``).join(', ') : '_(none)_'}`,
``,
`## Translate`,
``,
translatedNotes(ex),
``,
`## Rebuild`,
``,
rebuildSection(ex),
``,
`## Deferred`,
``,
deferredSection(ex),
``,
`## Dropped`,
``,
`Customizations targeting v1-only surfaces (IPC, credential-proxy, monolithic \`src/db.ts\`, \`task-scheduler.ts\`, pino) do not survive the migration. Review \`v1-data/git-customizations.json\` and re-express any surviving intent against v2's module system (see docs/module-contract.md).`,
``,
`## Rollback`,
``,
`After the swap, the pre-migration state is preserved at:`,
``,
`- Git tag \`pre-v2-<hash>-<ts>\` (restore code with \`git reset --hard <tag>\`)`,
`- \`store.v1-backup/\` (restore v1 DB with \`mv store.v1-backup store\`)`,
`- \`data/ipc.v1-backup/\` (restore v1 IPC with \`mv data/ipc.v1-backup data/ipc\`)`,
``,
`Delete \`data/v2.db\` after restoring to drop the v2 central state.`,
``,
];
return sections.join('\n');
}
export function writeGuide(ex: V1ExtractResult): string {
const outPath = path.join(ex.v1Root, '.nanoclaw-migrations', 'guide.md');
fs.writeFileSync(outPath, composeGuide(ex));
return outPath;
}
// ── section builders ──
function seedPlanTables(ex: V1ExtractResult): string {
const rows: string[] = [];
const folders = new Set<string>();
for (const g of ex.registeredGroups) folders.add(g.folder);
rows.push(`**Agent groups** (${folders.size}):`);
rows.push('');
for (const folder of folders) {
const name = ex.registeredGroups.find((g) => g.folder === folder)?.name ?? folder;
rows.push(`- \`${folder}\`${name}`);
}
rows.push('');
rows.push(`**Messaging groups + wirings** (${ex.registeredGroups.length}):`);
rows.push('');
rows.push('| channel_type | platform_id | folder | engage_mode | engage_pattern |');
rows.push('|---|---|---|---|---|');
for (const g of ex.registeredGroups) {
const { engage_mode, engage_pattern } = deriveEngage(g);
rows.push(
`| ${g.inferred_channel_type || '**UNKNOWN**'} | \`${g.jid}\` | \`${g.folder}\` | ${engage_mode} | ${engage_pattern ? `\`${engage_pattern}\`` : '—'} |`,
);
}
if (ex.unknownJids.length > 0) {
rows.push('');
rows.push(`> ⚠ ${ex.unknownJids.length} JID(s) could not be classified — edit \`v1-data/registered-groups.json\` before seeding.`);
}
return rows.join('\n');
}
function skillsToInstall(ex: V1ExtractResult): string {
if (ex.requiredChannelSkills.length === 0 && ex.appliedSkillMerges.length === 0) {
return '_(none)_';
}
const lines: string[] = [];
lines.push('**Channel skills** (required by seed — the seeder fails if missing):');
lines.push('');
if (ex.requiredChannelSkills.length === 0) {
lines.push('_(none)_');
} else {
for (const s of ex.requiredChannelSkills) lines.push(`- [ ] \`${s}\``);
}
const nonChannel = ex.appliedSkillMerges.filter(
(m) => m.v2_install && !ex.requiredChannelSkills.includes(m.v2_install),
);
if (nonChannel.length > 0) {
lines.push('');
lines.push('**Other previously-applied skills:**');
lines.push('');
for (const m of nonChannel) lines.push(`- [ ] \`${m.v2_install}\` (was \`${m.branch}\`)`);
}
return lines.join('\n');
}
function translatedNotes(ex: V1ExtractResult): string {
const lines: string[] = [];
lines.push('- **Triggers** (v1 global `TRIGGER_PATTERN` → per-wiring `engage_mode` + `engage_pattern`) — seeded automatically');
lines.push('- **Container configs** (v1 `registered_groups.container_config` column → `groups/<folder>/container.json`) — seeded automatically');
const hasExplicitAllow = ex.senderAllowlist?.chats && Object.keys(ex.senderAllowlist.chats).length > 0;
if (hasExplicitAllow) {
lines.push('- **Sender allowlist explicit entries** → `users` + `agent_group_members` rows — seeded automatically');
} else {
lines.push('- **Sender allowlist** was wildcard (`"*"`) or absent — no member rows seeded; set `unknown_sender_policy` per messaging group to control access');
}
lines.push('- **Owner + admin** (`users(role=owner)` + `NANOCLAW_ADMIN_USER_IDS`) — seeded automatically from `owner.json`');
return lines.join('\n');
}
function rebuildSection(ex: V1ExtractResult): string {
if (ex.customizedFiles.length === 0) {
return '_No user-authored source customizations detected._';
}
const lines: string[] = [
`Files changed since the v1 merge base (${ex.customizedFiles.length}). The sequencer offers a Claude handoff at the \`rebuild\` step so you can walk these through interactively:`,
'',
];
const hot = ex.customizedFiles
.slice()
.sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions))
.slice(0, 20);
for (const f of hot) {
lines.push(`- \`${f.path}\` (+${f.additions} / -${f.deletions})`);
}
if (ex.customizedFiles.length > hot.length) {
lines.push(`- … and ${ex.customizedFiles.length - hot.length} more (see \`v1-data/git-customizations.json\`)`);
}
return lines.join('\n');
}
function deferredSection(ex: V1ExtractResult): string {
const lines: string[] = [];
if (ex.scheduledTasks.length > 0) {
lines.push(
`**${ex.scheduledTasks.length} scheduled task(s)** live in \`v1-data/scheduled-tasks.json\`. ` +
`v2 stores tasks in per-session \`messages_in\` rows, not the central DB — ` +
`they can't be seeded directly. After first DM contact with the agent, paste the list so it can call its scheduling tool.`,
);
}
if (ex.chatRowCount > 0) {
lines.push(
`**${ex.chatRowCount} chat metadata row(s)** from v1 are not migrated — v2 doesn't keep a central \`chats\` table. ` +
`The v1 DB is preserved at \`store.v1-backup/messages.db\` if you need to extract history separately.`,
);
}
if (lines.length === 0) return '_(nothing deferred)_';
return lines.join('\n\n');
}
function deriveEngage(g: {
trigger_pattern: string;
requires_trigger: number;
}): { engage_mode: string; engage_pattern: string | null } {
if (g.trigger_pattern) return { engage_mode: 'pattern', engage_pattern: g.trigger_pattern };
if (g.requires_trigger === 0) return { engage_mode: 'pattern', engage_pattern: '.' };
return { engage_mode: 'mention', engage_pattern: null };
}
+76
View File
@@ -0,0 +1,76 @@
/**
* v1 JID → v2 channel_type inference.
*
* v1 rolled all platforms into one `registered_groups.jid` column using
* platform-specific prefixes/suffixes. v2 splits `channel_type` + `platform_id`
* as separate columns, so we have to parse the v1 JID back out.
*
* Returns `channel_type: 'unknown'` for unrecognized formats — the caller
* (extractor, seeder) must fail loudly rather than guess.
*/
export interface ChannelInference {
channel_type: string;
is_group: number; // 0 | 1 — best effort from the JID alone
}
export function inferChannelTypeFromJid(jid: string): ChannelInference {
if (jid.endsWith('@s.whatsapp.net')) return { channel_type: 'whatsapp', is_group: 0 };
if (jid.endsWith('@g.us')) return { channel_type: 'whatsapp', is_group: 1 };
// Telegram group IDs are negative; individual chat IDs are positive. v1
// encoded them as `tg:<id>`.
if (jid.startsWith('tg:')) return { channel_type: 'telegram', is_group: jid.slice(3).startsWith('-') ? 1 : 0 };
if (jid.startsWith('dc:')) return { channel_type: 'discord', is_group: 1 };
if (jid.startsWith('slack:')) return { channel_type: 'slack', is_group: 1 };
if (jid.startsWith('imsg:') || jid.startsWith('imessage:')) return { channel_type: 'imessage', is_group: 0 };
if (jid.startsWith('email:')) return { channel_type: 'resend', is_group: 0 };
if (jid.startsWith('matrix:')) return { channel_type: 'matrix', is_group: 1 };
if (jid.startsWith('linear:')) return { channel_type: 'linear', is_group: 1 };
if (jid.startsWith('github:')) return { channel_type: 'github', is_group: 1 };
if (jid.startsWith('webex:')) return { channel_type: 'webex', is_group: 1 };
if (jid.startsWith('gchat:')) return { channel_type: 'gchat', is_group: 1 };
if (jid.startsWith('wechat:')) return { channel_type: 'wechat', is_group: 0 };
if (jid.startsWith('teams:')) return { channel_type: 'teams', is_group: 1 };
return { channel_type: 'unknown', is_group: 0 };
}
/** `channel_type` → the `/add-<name>` skill that installs its adapter. */
export const CHANNEL_INSTALL_SKILL: Record<string, string> = {
whatsapp: '/add-whatsapp',
telegram: '/add-telegram',
discord: '/add-discord',
slack: '/add-slack',
imessage: '/add-imessage',
resend: '/add-resend',
matrix: '/add-matrix',
linear: '/add-linear',
github: '/add-github',
webex: '/add-webex',
gchat: '/add-gchat',
wechat: '/add-wechat',
teams: '/add-teams',
};
/** Convert an allowlist JID into a v2 user_id. Best-effort, never throws. */
export function userIdFromJid(jid: string): string {
if (jid.endsWith('@s.whatsapp.net')) {
const phone = jid.split('@')[0];
const normalised = phone.startsWith('+') ? phone : `+${phone}`;
return `phone:${normalised}`;
}
if (jid.endsWith('@g.us')) return `whatsapp:${jid}`; // groups in allowlists are unusual but keep them routable
if (jid.startsWith('tg:')) return `telegram:${jid.slice(3)}`;
if (jid.startsWith('dc:')) return `discord:${jid.slice(3)}`;
if (jid.startsWith('slack:')) return jid;
if (jid.startsWith('imsg:') || jid.startsWith('imessage:')) return `imessage:${jid.split(':').slice(1).join(':')}`;
if (jid.startsWith('email:')) return jid;
if (jid.startsWith('matrix:') || jid.startsWith('linear:') || jid.startsWith('github:') || jid.startsWith('webex:') || jid.startsWith('gchat:') || jid.startsWith('wechat:') || jid.startsWith('teams:')) return jid;
if (jid.includes('@')) return `phone:+${jid.split('@')[0]}`;
return `unknown:${jid}`;
}
export function splitUserId(userId: string): { kind: string; handle: string } {
const idx = userId.indexOf(':');
if (idx === -1) return { kind: 'unknown', handle: userId };
return { kind: userId.slice(0, idx), handle: userId.slice(idx + 1) };
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Infer the owner user_id from v1 state. The owner is the one user who
* gets the global `owner` role in v2 and receives approval prompts.
*
* Inference order (highest confidence first):
* 1. `.env` OWNER_USER_ID / OWNER_JID / OWNER_PHONE
* 2. The single registered group row with is_main=1
* 3. sender-allowlist.json with a single explicit allow entry
*
* Returns null user_id when inference fails — the caller must prompt.
*/
import { inferChannelTypeFromJid } from './jid.js';
export interface OwnerProposal {
userId: string | null;
source: string;
confidence: 'high' | 'medium' | 'low' | 'none';
}
export interface RegisteredGroupLite {
jid: string;
is_main: boolean;
inferred_channel_type: string;
}
interface AllowlistChat {
allow: '*' | string[];
}
export interface V1Allowlist {
default?: AllowlistChat;
chats?: Record<string, AllowlistChat>;
}
export function proposeOwner(
env: Record<string, string>,
registered: RegisteredGroupLite[],
allowlist: V1Allowlist | null,
): OwnerProposal {
if (env.OWNER_USER_ID) {
return { userId: env.OWNER_USER_ID, source: '.env OWNER_USER_ID', confidence: 'high' };
}
if (env.OWNER_JID) {
const channel = inferChannelTypeFromJid(env.OWNER_JID).channel_type;
return { userId: `${channel}:${env.OWNER_JID}`, source: '.env OWNER_JID', confidence: 'high' };
}
if (env.OWNER_PHONE) {
const phone = env.OWNER_PHONE.startsWith('+') ? env.OWNER_PHONE : `+${env.OWNER_PHONE}`;
return { userId: `phone:${phone}`, source: '.env OWNER_PHONE', confidence: 'high' };
}
const main = registered.find((r) => r.is_main);
if (main) {
return {
userId: `${main.inferred_channel_type}:${main.jid}`,
source: `is_main group (${main.jid})`,
confidence: 'medium',
};
}
if (allowlist?.chats) {
const explicit: string[] = [];
for (const entry of Object.values(allowlist.chats)) {
if (entry && Array.isArray(entry.allow)) {
for (const v of entry.allow) explicit.push(v);
}
}
if (explicit.length === 1) {
const channel = inferChannelTypeFromJid(explicit[0]).channel_type;
return {
userId: `${channel}:${explicit[0]}`,
source: 'sender-allowlist.json single entry',
confidence: 'medium',
};
}
}
return { userId: null, source: 'none', confidence: 'none' };
}
+472
View File
@@ -0,0 +1,472 @@
/**
* Seed the v2 central DB from an extracted v1 state bundle.
*
* Runs from a v2 worktree. Reads `<v1Root>/.nanoclaw-migrations/v1-data/*.json`
* and writes into `<v2Root>/data/v2.db`, matching v1 defaults onto v2's
* entity model:
*
* v1 registered_groups.folder → agent_groups
* v1 registered_groups.jid → messaging_groups (+ wiring row)
* v1 trigger_pattern → engage_mode + engage_pattern (migration 010)
* v1 container_config JSON → groups/<folder>/container.json
* v1 sender-allowlist explicit → users + agent_group_members
* owner proposal → users + user_roles(owner) + user_dms
*
* Idempotent: natural-key dedupe on every insert. Safe to re-run.
*
* Fails loudly when:
* - the owner's channel_type has no adapter file in src/channels/
* - any JID in registered-groups.json has inferred_channel_type='unknown'
*
* The caller is expected to install channel skills via `/add-<name>`
* before invoking — this function only checks that the adapters are
* present on disk, not that they connect.
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { initDb, closeDb, getDb } from '../../src/db/connection.js';
import { runMigrations } from '../../src/db/migrations/index.js';
import { createAgentGroup, getAgentGroupByFolder } from '../../src/db/agent-groups.js';
import {
createMessagingGroup,
getMessagingGroupByPlatform,
createMessagingGroupAgent,
} from '../../src/db/messaging-groups.js';
import { upsertUser } from '../../src/modules/permissions/db/users.js';
import { grantRole, isOwner } from '../../src/modules/permissions/db/user-roles.js';
import { addMember } from '../../src/modules/permissions/db/agent-group-members.js';
import { upsertUserDm } from '../../src/modules/permissions/db/user-dms.js';
import { initContainerConfig, writeContainerConfig } from '../../src/container-config.js';
import type { ContainerConfig } from '../../src/container-config.js';
import type {
AgentGroup,
MessagingGroup,
MessagingGroupAgent,
UnknownSenderPolicy,
User,
} from '../../src/types.js';
import { splitUserId, userIdFromJid } from './jid.js';
export interface SeedOptions {
/** Absolute path to v1 project root (where `.nanoclaw-migrations/` lives). */
v1Root: string;
/** Absolute path to v2 central DB. Defaults to `<cwd>/data/v2.db`. */
v2DbPath?: string;
/** Don't mutate — validate inputs and report what would be inserted. */
dryRun?: boolean;
}
export interface SeedStats {
agentGroups: { inserted: number; skipped: number };
messagingGroups: { inserted: number; skipped: number };
wirings: { inserted: number; skipped: number };
users: { inserted: number };
roles: { inserted: number; skipped: number };
members: { inserted: number };
userDms: { inserted: number };
containerConfigs: { written: number };
warnings: string[];
}
// ── Public entry point ──
export function runSeed(opts: SeedOptions): SeedStats {
const v1Data = path.join(opts.v1Root, '.nanoclaw-migrations', 'v1-data');
if (!fs.existsSync(v1Data)) {
throw new Error(`v1 data directory not found: ${v1Data} (run the extract step first)`);
}
const dbPath = opts.v2DbPath ?? path.resolve(process.cwd(), 'data', 'v2.db');
initDb(dbPath);
runMigrations(getDb());
try {
return seed(v1Data, opts.dryRun === true);
} finally {
closeDb();
}
}
// ── Internal: types matching extract-v1.ts output ──
interface V1GroupRow {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
container_config: unknown | null;
requires_trigger: number;
is_main: boolean;
inferred_channel_type: string;
inferred_is_group: number;
}
interface V1Owner {
userId: string | null; // latest extractor shape
user_id?: string | null; // back-compat with the older scripts
source: string;
confidence: string;
}
interface V1AllowlistEntry {
allow: '*' | string[];
}
interface V1Allowlist {
default?: V1AllowlistEntry;
chats?: Record<string, V1AllowlistEntry>;
}
// v1 container_config shape (kept inline — we don't ship v1 types into v2).
interface V1AdditionalMount {
hostPath: string;
containerPath?: string;
readonly?: boolean;
}
interface V1ContainerConfig {
additionalMounts?: V1AdditionalMount[];
timeout?: number;
}
// ── Seed logic ──
function seed(v1Data: string, dryRun: boolean): SeedStats {
const stats: SeedStats = {
agentGroups: { inserted: 0, skipped: 0 },
messagingGroups: { inserted: 0, skipped: 0 },
wirings: { inserted: 0, skipped: 0 },
users: { inserted: 0 },
roles: { inserted: 0, skipped: 0 },
members: { inserted: 0 },
userDms: { inserted: 0 },
containerConfigs: { written: 0 },
warnings: [],
};
const v1Groups = readJson<V1GroupRow[]>(path.join(v1Data, 'registered-groups.json'));
const ownerFile = readJson<V1Owner>(path.join(v1Data, 'owner.json'));
const env = readJson<Record<string, string>>(path.join(v1Data, 'env.json'));
const allowlist = readJson<V1Allowlist>(path.join(v1Data, 'sender-allowlist.json'));
const ownerUserId = ownerFile.userId ?? ownerFile.user_id ?? null;
// Pre-flight: channel adapters on disk. Matches how channels self-register
// in src/channels/index.ts — if a file named <channel_type>.ts exists
// (and isn't infra), the adapter is installed.
const channelsInstalled = readInstalledChannels();
const requiredChannels = new Set<string>();
for (const g of v1Groups) {
if (g.inferred_channel_type === 'unknown') {
stats.warnings.push(
`JID '${g.jid}' has inferred_channel_type='unknown' — edit registered-groups.json to set it before seeding`,
);
continue;
}
requiredChannels.add(g.inferred_channel_type);
}
const missing = [...requiredChannels].filter((c) => !channelsInstalled.has(c));
if (missing.length > 0) {
throw new Error(
`Channel adapters not installed: ${missing.join(', ')}. ` +
`Install them via /add-<name> skills in this worktree before seeding.`,
);
}
if (dryRun) {
stats.warnings.push(`DRY RUN — no writes. Would insert ${v1Groups.length} groups, owner=${ownerUserId ?? 'unresolved'}.`);
return stats;
}
// ── Agent groups (unique by folder) ──
const folderToAgentGroupId = new Map<string, string>();
for (const g of v1Groups) {
if (folderToAgentGroupId.has(g.folder)) continue;
const existing = getAgentGroupByFolder(g.folder);
if (existing) {
folderToAgentGroupId.set(g.folder, existing.id);
stats.agentGroups.skipped++;
} else {
const row: AgentGroup = {
id: shortId('ag'),
name: g.name,
folder: g.folder,
agent_provider: null,
created_at: nowIso(),
};
createAgentGroup(row);
folderToAgentGroupId.set(g.folder, row.id);
stats.agentGroups.inserted++;
}
// Initialize the per-group container.json. v1 container_config (if any)
// gets translated into the v2 shape.
initContainerConfig(g.folder);
if (g.container_config && typeof g.container_config === 'object') {
try {
writeContainerConfig(g.folder, translateContainerConfig(g.container_config));
stats.containerConfigs.written++;
} catch (err) {
stats.warnings.push(`Could not write container.json for ${g.folder}: ${(err as Error).message}`);
}
}
}
// ── Messaging groups + wirings ──
const jidToMgId = new Map<string, string>();
for (const g of v1Groups) {
if (g.inferred_channel_type === 'unknown') continue;
const agId = folderToAgentGroupId.get(g.folder);
if (!agId) continue;
let mg = getMessagingGroupByPlatform(g.inferred_channel_type, g.jid);
if (!mg) {
const row: MessagingGroup = {
id: shortId('mg'),
channel_type: g.inferred_channel_type,
platform_id: g.jid,
name: g.name,
is_group: g.inferred_is_group,
// 'strict' is the conservative choice for a migration — existing
// allowlist semantics (trigger-required for unknowns) translate to
// "drop unknowns by default". Users can switch to 'request_approval'
// later to opt into the new auto-register flow.
unknown_sender_policy: 'strict' satisfies UnknownSenderPolicy,
created_at: nowIso(),
};
createMessagingGroup(row);
stats.messagingGroups.inserted++;
mg = row;
} else {
stats.messagingGroups.skipped++;
}
jidToMgId.set(g.jid, mg.id);
// Wiring — dedupe by (messaging_group_id, agent_group_id)
const existingWiring = getDb()
.prepare('SELECT id FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?')
.get(mg.id, agId) as { id: string } | undefined;
if (existingWiring) {
stats.wirings.skipped++;
continue;
}
createMessagingGroupAgent(buildWiring(mg.id, agId, g, env));
stats.wirings.inserted++;
}
// ── Owner user + role + DM + membership ──
if (ownerUserId) {
const { kind, handle } = splitUserId(ownerUserId);
const user: User = {
id: ownerUserId,
kind,
display_name: 'Owner',
created_at: nowIso(),
};
upsertUser(user);
stats.users.inserted++;
if (!isOwner(ownerUserId)) {
grantRole({
user_id: ownerUserId,
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: nowIso(),
});
stats.roles.inserted++;
} else {
stats.roles.skipped++;
}
// DM cache — prefer exact-JID match, fall back to the first is_group=0
// messaging_group on the owner's channel.
const ownerChannel = kind === 'phone' ? 'whatsapp' : kind;
const directMgId = jidToMgId.get(handle) ?? findDmMessagingGroup(ownerChannel);
if (directMgId) {
upsertUserDm({
user_id: ownerUserId,
channel_type: ownerChannel,
messaging_group_id: directMgId,
resolved_at: nowIso(),
});
stats.userDms.inserted++;
} else {
stats.warnings.push(
`Could not resolve DM channel for owner ${ownerUserId} on channel '${ownerChannel}'. ` +
`The owner must DM the bot once so the DM channel gets cached.`,
);
}
// Owner is implicit member of every agent group — make it explicit so
// getMembers(agId) returns a non-empty set for the UI.
for (const agId of folderToAgentGroupId.values()) {
addMember({
user_id: ownerUserId,
agent_group_id: agId,
added_by: null,
added_at: nowIso(),
});
stats.members.inserted++;
}
} else {
stats.warnings.push(
'No owner resolved — run the extract step again with OWNER_USER_ID set, or hand-edit owner.json before re-seeding.',
);
}
// ── Allowlist → members ──
if (allowlist?.chats) {
for (const [jid, entry] of Object.entries(allowlist.chats)) {
if (!entry || entry.allow === '*' || !Array.isArray(entry.allow)) continue;
const agId = findAgentGroupForJid(jid, v1Groups, folderToAgentGroupId);
if (!agId) continue;
for (const allowedJid of entry.allow) {
const memberId = userIdFromJid(allowedJid);
const { kind } = splitUserId(memberId);
upsertUser({
id: memberId,
kind,
display_name: null,
created_at: nowIso(),
});
stats.users.inserted++;
addMember({
user_id: memberId,
agent_group_id: agId,
added_by: ownerUserId,
added_at: nowIso(),
});
stats.members.inserted++;
}
}
}
return stats;
}
// ── Wiring builder ──
function buildWiring(
messagingGroupId: string,
agentGroupId: string,
g: V1GroupRow,
env: Record<string, string>,
): MessagingGroupAgent {
const assistantName = env.ASSISTANT_NAME ?? 'Andy';
const defaultTrigger = `@${assistantName}`;
// Map v1 (requires_trigger, trigger_pattern) onto v2 engage-mode semantics.
// Mirrors migration 010's backfill logic so in-place and from-scratch seeds
// converge on the same rows.
let engage_mode: 'pattern' | 'mention' | 'mention-sticky' = 'mention';
let engage_pattern: string | null = null;
const pattern =
typeof g.trigger_pattern === 'string' && g.trigger_pattern.length > 0 ? g.trigger_pattern : null;
if (pattern) {
engage_mode = 'pattern';
engage_pattern = pattern;
} else if (g.requires_trigger === 0) {
// v1 rows with requires_trigger=0 responded to every message. v2's
// "always" sentinel is engage_pattern='.'.
engage_mode = 'pattern';
engage_pattern = '.';
} else {
// No explicit pattern, trigger required → v2 mention mode with the
// default trigger derived from ASSISTANT_NAME.
engage_mode = 'mention';
engage_pattern = null;
void defaultTrigger; // kept for readability; v2's mention mode resolves this at runtime
}
return {
id: shortId('mga'),
messaging_group_id: messagingGroupId,
agent_group_id: agentGroupId,
engage_mode,
engage_pattern,
sender_scope: 'all', // v1 gated unknowns via the sender-allowlist file, not per-wiring
ignored_message_policy: 'drop', // no v1 analog; conservative
session_mode: 'shared', // v1 had one session per group, not per-thread
priority: 0,
created_at: nowIso(),
};
}
// ── v1 → v2 container config translation ──
function translateContainerConfig(v1: unknown): ContainerConfig {
const c = (v1 ?? {}) as V1ContainerConfig;
const mounts = (c.additionalMounts ?? []).map((m) => ({
hostPath: m.hostPath,
containerPath: m.containerPath ?? `/workspace/extra/${path.basename(m.hostPath)}`,
readonly: m.readonly ?? true,
}));
return {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: mounts,
skills: 'all',
};
}
// ── helpers ──
function readInstalledChannels(): Set<string> {
const channelsDir = path.join(process.cwd(), 'src', 'channels');
const installed = new Set<string>();
if (!fs.existsSync(channelsDir)) return installed;
// Infra files — not adapters. Keep in sync with the actual contents of
// src/channels/ on trunk (see channel-registry.ts + index.ts imports).
const infra = new Set(['adapter', 'ask-question', 'channel-registry', 'chat-sdk-bridge', 'cli', 'index']);
for (const entry of fs.readdirSync(channelsDir)) {
if (!entry.endsWith('.ts') || entry.endsWith('.test.ts')) continue;
const name = entry.slice(0, -3);
if (infra.has(name)) continue;
installed.add(name);
}
// CLI always available (lives on trunk) — surface it explicitly since the
// infra filter above would otherwise hide it.
installed.add('cli');
return installed;
}
function findAgentGroupForJid(
jid: string,
v1Groups: V1GroupRow[],
folderToAgentGroupId: Map<string, string>,
): string | null {
const match = v1Groups.find((g) => g.jid === jid);
if (!match) return null;
return folderToAgentGroupId.get(match.folder) ?? null;
}
function findDmMessagingGroup(channelType: string): string | undefined {
const row = getDb()
.prepare(
'SELECT id FROM messaging_groups WHERE channel_type = ? AND is_group = 0 ORDER BY created_at LIMIT 1',
)
.get(channelType) as { id: string } | undefined;
return row?.id;
}
function readJson<T>(p: string): T {
return JSON.parse(fs.readFileSync(p, 'utf-8')) as T;
}
function shortId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
}
function nowIso(): string {
return new Date().toISOString();
}