mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
3ee7d2147e
`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.
258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
/**
|
|
* v1 → v2 migration orchestrator. Called from setup/auto.ts after the
|
|
* timezone step and before the channel step.
|
|
*
|
|
* Silent happy path: if no v1 install is found, we emit one "skipped" step
|
|
* and return. Users on a fresh v2 install never see anything.
|
|
*
|
|
* When v1 IS found: detect → [confirm] → group-selection prompt → validate
|
|
* → db → groups → env → channel-auth → channels → tasks → handoff.
|
|
* Every sub-step is a separate entry in the progression log; failures never
|
|
* abort the chain (the handoff file records them for the skill to finish).
|
|
*
|
|
* After everything runs, a one-line note points the user at the
|
|
* `/migrate-from-v1` skill.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import * as p from '@clack/prompts';
|
|
import Database from 'better-sqlite3';
|
|
import k from 'kleur';
|
|
|
|
import { ensureAnswer, runQuietStep } from './lib/runner.js';
|
|
import { wrapForGutter } from './lib/theme.js';
|
|
import * as setupLog from './logs.js';
|
|
import {
|
|
HANDOFF_PATH,
|
|
MIGRATION_DIR,
|
|
inferChannelType,
|
|
readHandoff,
|
|
v1PathsFor,
|
|
writeHandoff,
|
|
} from './migrate-v1/shared.js';
|
|
|
|
/**
|
|
* Count groups in v1's registered_groups, split by whether the channel_type
|
|
* can be inferred. Uses the same `inferChannelType` logic as migrate-db so
|
|
* the displayed count matches what will actually get seeded. Open-and-close
|
|
* because this runs in the orchestrator before migrate-db's child process.
|
|
*/
|
|
function countV1Groups(v1Root: string): { total: number; wired: number } {
|
|
const dbPath = v1PathsFor(v1Root).db;
|
|
try {
|
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
const rows = db
|
|
.prepare('SELECT jid, channel_name FROM registered_groups')
|
|
.all() as Array<{ jid: string; channel_name: string | null }>;
|
|
db.close();
|
|
let wired = 0;
|
|
for (const r of rows) {
|
|
if (inferChannelType(r.jid, r.channel_name)) wired++;
|
|
}
|
|
return { total: rows.length, wired };
|
|
} catch {
|
|
return { total: 0, wired: 0 };
|
|
}
|
|
}
|
|
|
|
async function askGroupSelection(counts: { total: number; wired: number }): Promise<'all' | 'wired-only' | 'cancel'> {
|
|
// Non-interactive escape hatch for CI / re-runs / scripted migrations.
|
|
// NANOCLAW_MIGRATE_SELECTION = 'all' | 'wired-only' | 'cancel'.
|
|
const envChoice = process.env.NANOCLAW_MIGRATE_SELECTION?.trim();
|
|
if (envChoice === 'all' || envChoice === 'wired-only' || envChoice === 'cancel') {
|
|
setupLog.userInput('migrate_selection', `${envChoice} (from NANOCLAW_MIGRATE_SELECTION)`);
|
|
return envChoice;
|
|
}
|
|
// Most v1 installs accumulated many orphan folders. Default the user to
|
|
// wired-only (the ones we can actually route) — explicit opt-in for "all".
|
|
const choice = ensureAnswer(
|
|
await p.select({
|
|
message: `Found ${counts.total} v1 group folders (${counts.wired} wired to a channel). Which to bring over?`,
|
|
options: [
|
|
{
|
|
value: 'wired-only',
|
|
label: `Only the ${counts.wired} wired ones`,
|
|
hint: 'recommended — skips orphans',
|
|
},
|
|
{
|
|
value: 'all',
|
|
label: `All ${counts.total} folders`,
|
|
hint: 'brings dead/orphan folders over too',
|
|
},
|
|
{
|
|
value: 'cancel',
|
|
label: 'Skip migration',
|
|
hint: "I'll migrate later",
|
|
},
|
|
],
|
|
}),
|
|
) as 'all' | 'wired-only' | 'cancel';
|
|
setupLog.userInput('migrate_selection', choice);
|
|
return choice;
|
|
}
|
|
|
|
/**
|
|
* Finalize the handoff record after every sub-step has run. Computes an
|
|
* overall status from per-step statuses: anything `failed` → partial;
|
|
* anything `partial` → partial; else success.
|
|
*/
|
|
function finalizeHandoff(): 'success' | 'partial' | 'failed' {
|
|
const h = readHandoff();
|
|
const statuses = Object.values(h.steps).map((s) => s?.status);
|
|
const anyFailed = statuses.includes('failed');
|
|
const anyPartial = statuses.includes('partial');
|
|
const overall: 'success' | 'partial' | 'failed' = anyFailed
|
|
? 'partial' // DB or files may have landed; the skill can pick up the rest
|
|
: anyPartial
|
|
? 'partial'
|
|
: 'success';
|
|
h.overall_status = overall;
|
|
writeHandoff(h);
|
|
return overall;
|
|
}
|
|
|
|
function printHandoffNote(overall: 'success' | 'partial' | 'failed'): void {
|
|
const relHandoff = path.relative(process.cwd(), HANDOFF_PATH);
|
|
const lines: string[] = [];
|
|
if (overall === 'success') {
|
|
lines.push(
|
|
wrapForGutter(
|
|
'Your v1 install has been migrated. Run `/migrate-from-v1` in Claude next — it will seed your owner account and help port any custom code you had.',
|
|
4,
|
|
),
|
|
);
|
|
} else {
|
|
lines.push(
|
|
wrapForGutter(
|
|
'Migration finished with some items for a human. Run `/migrate-from-v1` in Claude — it will read the handoff, finish the unfinished steps, and walk through custom code.',
|
|
4,
|
|
),
|
|
);
|
|
}
|
|
lines.push('');
|
|
lines.push(k.dim(` Handoff: ${relHandoff}`));
|
|
lines.push(k.dim(` Full log: ${setupLog.progressLogPath}`));
|
|
lines.push(k.dim(` Raw logs: ${setupLog.stepsDir}/`));
|
|
p.note(lines.join('\n'), 'Migration handoff');
|
|
}
|
|
|
|
export async function runMigrateV1(): Promise<'proceeded' | 'skipped' | 'cancelled'> {
|
|
// 0. Ensure migration log dir exists before any sub-step writes to it.
|
|
fs.mkdirSync(MIGRATION_DIR, { recursive: true });
|
|
|
|
// 1. Detect. If nothing obvious, give the user one subtle chance to point
|
|
// us at a non-standard path — then accept silently.
|
|
const detect = await runQuietStep('migrate-detect', {
|
|
running: 'Checking for a previous NanoClaw install…',
|
|
done: 'Found a previous install.',
|
|
skipped: 'No previous install to migrate.',
|
|
});
|
|
|
|
const v1Found = detect.ok && detect.terminal?.fields.STATUS === 'success';
|
|
|
|
if (!v1Found) {
|
|
// Silent skip — the 99% case is a fresh install with no v1 anywhere.
|
|
// Prompting for a custom path on every fresh run is UX noise. Users
|
|
// with a v1 at a non-standard location use `NANOCLAW_V1_PATH=<path>
|
|
// bash nanoclaw.sh` (documented in README + setup/auto.ts header).
|
|
return 'skipped';
|
|
}
|
|
|
|
// 2. Ask the user which groups to bring over.
|
|
const h = readHandoff();
|
|
if (!h.v1_path) {
|
|
// Shouldn't happen — detect set it if v1Found. Guard anyway.
|
|
return 'skipped';
|
|
}
|
|
|
|
// Experimental warning — fires only when a v1 install is found, so stock
|
|
// v2 users (no v1 to migrate) never see it. Not a blocker; the user can
|
|
// still proceed. Skip when NANOCLAW_MIGRATE_SELECTION is set (scripted /
|
|
// CI runs have already accepted the risk by defining their selection).
|
|
if (!process.env.NANOCLAW_MIGRATE_SELECTION) {
|
|
p.log.warn(
|
|
wrapForGutter(
|
|
'v1 → v2 migration is experimental. Back up your v2 state (data/v2.db, groups/) before continuing. Not recommended for high-stakes production installs — it does a best-effort port and a human still has to finish via /migrate-from-v1.',
|
|
4,
|
|
),
|
|
);
|
|
}
|
|
|
|
const counts = countV1Groups(h.v1_path);
|
|
const selection = await askGroupSelection(counts);
|
|
if (selection === 'cancel') {
|
|
// Mark the handoff so the skill can still see what would have happened.
|
|
const ho = readHandoff();
|
|
ho.overall_status = 'skipped';
|
|
writeHandoff(ho);
|
|
return 'cancelled';
|
|
}
|
|
|
|
// 3. Validate — if it fails, subsequent steps will short-circuit the
|
|
// DB-dependent parts. Groups + env still run.
|
|
await runQuietStep('migrate-validate', {
|
|
running: "Checking the v1 database's shape…",
|
|
done: 'v1 database looks good.',
|
|
failed: "v1 database didn't match what I expected.",
|
|
skipped: 'Skipped database validation.',
|
|
});
|
|
|
|
// 4. DB seeding — parameterized by the user's selection.
|
|
await runQuietStep(
|
|
'migrate-db',
|
|
{
|
|
running: 'Seeding v2 agents and channels from v1…',
|
|
done: 'Seeded v2 database.',
|
|
skipped: 'Skipped database seeding.',
|
|
failed: "Couldn't seed the v2 database.",
|
|
},
|
|
['--selection', selection],
|
|
);
|
|
|
|
// 5. Group folders.
|
|
await runQuietStep('migrate-groups', {
|
|
running: 'Copying group folders…',
|
|
done: 'Group folders copied.',
|
|
skipped: 'Skipped group-folder copy.',
|
|
failed: "Couldn't copy some group folders.",
|
|
});
|
|
|
|
// 6. Env keys.
|
|
await runQuietStep('migrate-env', {
|
|
running: 'Merging v1 .env into v2 .env…',
|
|
done: 'Env keys migrated.',
|
|
skipped: 'No env keys to migrate.',
|
|
failed: "Couldn't merge .env.",
|
|
});
|
|
|
|
// 7. Non-env channel auth (Baileys keystore, matrix state, etc.).
|
|
await runQuietStep('migrate-channel-auth', {
|
|
running: 'Copying channel auth files…',
|
|
done: 'Channel auth copied.',
|
|
skipped: 'No channel auth to copy.',
|
|
failed: 'Some channel auth files need attention.',
|
|
});
|
|
|
|
// 8. Install v2 channel adapters for the detected channels.
|
|
await runQuietStep('migrate-channels', {
|
|
running: 'Installing v2 channel adapters…',
|
|
done: 'Channel adapters installed.',
|
|
skipped: 'No channels to install.',
|
|
failed: 'Some channel adapters need attention.',
|
|
});
|
|
|
|
// 9. Scheduled tasks.
|
|
await runQuietStep('migrate-tasks', {
|
|
running: 'Porting scheduled tasks…',
|
|
done: 'Scheduled tasks ported.',
|
|
skipped: 'No scheduled tasks to port.',
|
|
failed: 'Some scheduled tasks need attention.',
|
|
});
|
|
|
|
// 10. Finalize + hand off.
|
|
const overall = finalizeHandoff();
|
|
printHandoffNote(overall);
|
|
return 'proceeded';
|
|
}
|