Files
nanoclaw/setup/migrate-v2/groups.ts
exe.dev user 1d73b2986a feat: add migrate-v2.sh — standalone v1 → v2 migration script
New entry point: `bash migrate-v2.sh` from the v2 checkout.
Replaces the old setup-embedded migration flow with a standalone
4-phase script + rewritten Claude skill for the interactive parts.

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 20:13:38 +00:00

121 lines
3.9 KiB
TypeScript

/**
* migrate-v2 step: groups
*
* Copy v1 group folders into v2.
* - v1 CLAUDE.md → v2 CLAUDE.local.md (v2 composes CLAUDE.md at spawn)
* - v1 container_config → .v1-container-config.json sidecar
* - All other files copied (no overwrite)
* - Also copies global/ if it exists
*
* Idempotent — does not overwrite files that already exist in v2.
*
* Usage: pnpm exec tsx setup/migrate-v2/groups.ts <v1-path>
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']);
/** Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. */
function copyTree(src: string, dst: string): number {
let written = 0;
if (!fs.existsSync(src)) return 0;
fs.mkdirSync(dst, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (SKIP_NAMES.has(entry.name)) continue;
const s = path.join(src, entry.name);
const d = path.join(dst, entry.name);
if (entry.isDirectory()) {
written += copyTree(s, d);
continue;
}
if (fs.existsSync(d)) continue;
fs.copyFileSync(s, d);
written += 1;
}
return written;
}
function main(): void {
const v1Path = process.argv[2];
if (!v1Path) {
console.error('Usage: tsx setup/migrate-v2/groups.ts <v1-path>');
process.exit(1);
}
const v1GroupsDir = path.join(v1Path, 'groups');
const v2GroupsDir = path.join(process.cwd(), 'groups');
if (!fs.existsSync(v1GroupsDir)) {
console.log('SKIPPED:no v1 groups/ directory');
process.exit(0);
}
// Get all folders from v1 DB to know which groups are registered
const v1DbPath = path.join(v1Path, 'store', 'messages.db');
const registeredFolders = new Set<string>();
if (fs.existsSync(v1DbPath)) {
const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true });
const rows = v1Db
.prepare('SELECT folder, container_config FROM registered_groups')
.all() as Array<{ folder: string; container_config: string | null }>;
const containerConfigs = new Map<string, string | null>();
for (const r of rows) {
registeredFolders.add(r.folder);
containerConfigs.set(r.folder, r.container_config);
}
v1Db.close();
// Write container.json from v1 container_config.
// The additionalMounts shape is identical between v1 and v2.
for (const [folder, config] of containerConfigs) {
if (!config) continue;
const v2Folder = path.join(v2GroupsDir, folder);
const containerJson = path.join(v2Folder, 'container.json');
if (fs.existsSync(containerJson)) continue;
fs.mkdirSync(v2Folder, { recursive: true });
try {
const parsed = JSON.parse(config) as Record<string, unknown>;
fs.writeFileSync(containerJson, JSON.stringify(parsed, null, 2));
} catch {
// Unparseable config — write as sidecar for the skill to handle
fs.writeFileSync(path.join(v2Folder, '.v1-container-config.json'), config);
}
}
}
// Copy all v1 group folders (registered + global + any extras)
let foldersCopied = 0;
let claudesMigrated = 0;
let filesCopied = 0;
for (const entry of fs.readdirSync(v1GroupsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const folder = entry.name;
const v1Folder = path.join(v1GroupsDir, folder);
const v2Folder = path.join(v2GroupsDir, folder);
fs.mkdirSync(v2Folder, { recursive: true });
// CLAUDE.md → CLAUDE.local.md
const v1Claude = path.join(v1Folder, 'CLAUDE.md');
const v2Local = path.join(v2Folder, 'CLAUDE.local.md');
if (fs.existsSync(v1Claude) && !fs.existsSync(v2Local)) {
fs.copyFileSync(v1Claude, v2Local);
claudesMigrated++;
}
// Copy everything else
filesCopied += copyTree(v1Folder, v2Folder);
foldersCopied++;
}
console.log(`OK:folders=${foldersCopied},claudes=${claudesMigrated},files=${filesCopied}`);
}
main();