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>
121 lines
3.9 KiB
TypeScript
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();
|