Files
nanoclaw/setup/migrate-v1/detect.ts
gabi-simons 3ee7d2147e feat: add v1 → v2 migration to setup flow (experimental)
`bash nanoclaw.sh` detects a v1 install before channel pairing and does a
best-effort automated port of operationally important state. Hands off to
a new `/migrate-from-v1` skill for owner seeding and fork customizations.

Between the timezone and channel steps, `setup/auto.ts` calls
`runMigrateV1()` which orchestrates these registered sub-steps (each a
separate entry in the progression log with its own raw log + status
block — failures never abort the chain):

- **migrate-detect** — scans siblings of the v2 checkout + common $HOME
  locations; `$NANOCLAW_V1_PATH` overrides authoritatively. Relaxed
  `package.json` check lets forks + partial installs still match; DB
  presence is the strongest signal.
- **migrate-validate** — asserts v1 DB shape (tables + required
  columns); writes `schema-mismatch.json` on failure. Subsequent steps
  short-circuit their DB-dependent parts but still run.
- **migrate-db** — seeds `agent_groups` + `messaging_groups` +
  `messaging_group_agents` from v1's `registered_groups`. JID
  decomposition (`dc:123` → `channel_type='discord'`,
  `platform_id='discord:123'`); `trigger_pattern` + `requires_trigger`
  → `engage_mode` + `engage_pattern` (mirrors migration 010 backfill).
  Users + user_roles are NOT seeded — the skill does that with an owner
  interview. Idempotent: existing rows reused, not duplicated.
- **migrate-groups** — rsync group folders. v1 `CLAUDE.md` → v2
  `CLAUDE.local.md` (v2 composes `CLAUDE.md` at container spawn); v1
  `container_config` JSON → `.v1-container-config.json` sidecar for the
  skill to translate. Tight v1-pattern scan (`/workspace/ipc/tasks`,
  `store/messages.db`, `[PR_CONTEXT:`, etc.) flags files referencing
  v1-specific infrastructure — content is NOT modified, just flagged in
  the handoff.
- **migrate-env** — merges v1 `.env` into v2 `.env`, never overwriting
  existing v2 keys.
- **migrate-channel-auth** — per-channel registry tracks v1 env keys,
  v2 required keys (with source-of-key instructions — e.g. Discord
  needs `DISCORD_PUBLIC_KEY` which v1 never stored), and candidate
  on-disk auth state paths (Baileys keystore, matrix sync state,
  etc.). Missing required v2 keys surface as actionable followups and
  flip the step to `partial`.
- **migrate-channels** — runs `setup/install-<channel>.sh` for each
  detected channel in non-interactive mode. Install-script output is
  captured to `logs/setup-migration/install-<channel>.log` sidecars
  (silent under the parent spinner). Channels with no v2 adapter get
  a `not_supported` followup but don't degrade status.
- **migrate-tasks** — v1 `scheduled_tasks` → `messages_in` rows with
  `kind='task'` in each session's `inbound.db`. `schedule_type`
  mapping (cron / interval / once → v2 cron). Idempotent: skips v1
  task ids already present. Inactive rows dumped to
  `inactive-tasks.json` for reference.

Everything writes to `logs/setup-migration/handoff.json` — the source
of truth the skill consumes.

`.claude/skills/migrate-from-v1/SKILL.md`:

- **Phase A** (always): owner seeding + v1 access policy flip
  (`unknown_sender_policy` public/strict) via `AskUserQuestion`. Pulls
  sender candidates from v1's `messages` table as hints.
- **Phase B** (if followups exist): walks
  `handoff.followups` — translates `.v1-container-config.json`
  sidecars, handles `not_supported` channels, fills in missing
  required keys with instructions on where to get them.
- **Phase C** (fork-aware): `git log <upstream>..HEAD` in v1. Empty →
  "no customizations to port." Non-empty → scope choice (mechanical /
  full interview / reference-only). Portable categories
  (`container/skills/*`, `.claude/skills/*`, docs) scan+copy with
  `scanForV1Patterns`. Non-portable (`src/*`,
  `container/agent-runner/src/*`) stash to `docs/v1-fork-reference/`
  — explicit "don't translate v1 infra to v2" warning because v1's
  IPC file queue / single DB don't exist in v2.

Clearly marked in README, CLAUDE.md, SKILL.md header, and via a `p.warn`
that fires once per run when v1 is detected. Users with no v1 install
see a silent skip — no prompts, no noise.

Verified end-to-end against a live v1 install (300 discord + 1
discord-supervisor groups, fork with ~15 commits of PR-factory work):
- Detect → validate → db (301 rows seeded) → groups (301 CLAUDE.local.md
  + 178 other files + 1 container_config sidecar) → env (4 keys copied)
  → channel-auth (flagged missing `DISCORD_APPLICATION_ID` +
  `DISCORD_PUBLIC_KEY`) → channels (discord installed, discord-supervisor
  → not_supported) → tasks (0 rows, skipped)
- Idempotent re-run: 0 rows created, 903 rows reused; tasks skip if
  id already present
- Fresh-user case: silent skip, no prompts, straight to "You're ready!"
- Schema-mismatch case: recorded to `schema-mismatch.json`, chain
  continues

- Unit tests for the pure transforms (`parseJid`,
  `inferChannelType`, `triggerToEngage`, `scanForV1Patterns`,
  `looksLikeV1Install`)
- Validate `requiredV2Keys` for telegram/slack/matrix/teams/webex/
  resend/linear against the actual Chat SDK packages (Discord was
  verified from real error output)
- Widen candidate auth file paths for WhatsApp/Matrix/iMessage based
  on real non-Discord v1 installs once we have some

See docs/v1-to-v2-changes.md for the v1 → v2 architecture diff.
2026-04-23 13:06:14 +00:00

108 lines
3.2 KiB
TypeScript

/**
* Step: migrate-detect
*
* Find a v1 install on disk. Scans the standard candidate paths; if none
* matches, exits with a NOT_FOUND status (the orchestrator then offers a
* clack prompt so the user can point at a custom path).
*
* Never prompts — this step is pure discovery so it stays safe to run under
* NANOCLAW_SKIP= without blocking on stdin.
*/
import fs from 'fs';
import path from 'path';
import { emitStatus } from '../status.js';
import {
defaultV1Candidates,
looksLikeV1Install,
readHandoff,
recordStep,
v1PathsFor,
writeHandoff,
} from './shared.js';
interface DetectArgs {
/** Explicit path to check, skipping the default candidate list. */
path?: string;
}
function parseArgs(args: string[]): DetectArgs {
const out: DetectArgs = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--path') {
out.path = args[++i] || undefined;
}
}
return out;
}
export async function run(args: string[]): Promise<void> {
const parsed = parseArgs(args);
// An explicit path — either from --path or $NANOCLAW_V1_PATH — is
// authoritative. If it doesn't validate, we don't fall through to
// the default candidate list. That keeps the user's explicit intent
// from being silently overridden.
const envOverride = process.env.NANOCLAW_V1_PATH?.trim();
const explicit = parsed.path ?? envOverride ?? null;
const candidates = explicit ? [explicit] : defaultV1Candidates();
for (const candidate of candidates) {
const absolute = path.resolve(candidate);
// Don't self-match — if the candidate resolves to the v2 checkout we're
// running inside, skip it. Protects users who cloned v2 into `~/nanoclaw`
// after deleting v1.
if (absolute === path.resolve(process.cwd())) continue;
const check = looksLikeV1Install(absolute);
if (!check.ok) continue;
const paths = v1PathsFor(absolute);
let version = 'unknown';
try {
const pkg = JSON.parse(fs.readFileSync(paths.packageJson, 'utf-8')) as { version?: string };
version = pkg.version ?? 'unknown';
} catch {
// Already sanity-checked by looksLikeV1Install — a failure here means
// the file changed under us between calls. Unlikely, not fatal.
}
const h = readHandoff();
h.v1_path = absolute;
h.v1_version = version;
writeHandoff(h);
recordStep('migrate-detect', {
status: 'success',
fields: { V1_PATH: absolute, V1_VERSION: version },
notes: [],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DETECT', {
STATUS: 'success',
V1_PATH: absolute,
V1_VERSION: version,
DB_PATH: paths.db,
ENV_PATH: paths.env,
GROUPS_PATH: paths.groups,
});
return;
}
// Nothing matched. Not an error — most v2 installs are fresh, not migrations.
const scanned = candidates.map((c) => path.resolve(c)).join(',');
recordStep('migrate-detect', {
status: 'skipped',
fields: { REASON: 'no-v1-install-found' },
notes: [`Scanned: ${scanned}`],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DETECT', {
STATUS: 'skipped',
REASON: 'not_found',
CANDIDATES_SCANNED: String(candidates.length),
});
}