diff --git a/setup/auto.ts b/setup/auto.ts index ab0cbb42d..147eed4e7 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,10 +14,8 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|timezone|migration|channel| + * service|cli-agent|timezone|channel| * verify|first-chat) - * NANOCLAW_V1_PATH explicit path to a v1 install to migrate - * from (default: scan common locations) * * Timezone is auto-detected after the CLI agent step. UTC resolves are * confirmed with the user, and free-text replies fall through to a @@ -41,7 +39,6 @@ import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; -import { runMigrateV1 } from './migrate-v1.js'; import { applyToEnv, parseFlags, @@ -437,13 +434,8 @@ async function main(): Promise { await runTimezoneStep(); } - if (!skip.has('migration')) { - // Runs silently when there's no v1 install; otherwise orchestrates the - // detect → validate → db → groups → env → channel-auth → channels → - // tasks sub-steps and writes logs/setup-migration/handoff.json for the - // /migrate-from-v1 skill to pick up. - await runMigrateV1(); - } + // v1 → v2 migration is handled by `bash migrate-v2.sh`, not the setup flow. + // Users migrating from v1 run that script before (or instead of) setup. let channelChoice: ChannelChoice = 'skip'; diff --git a/setup/index.ts b/setup/index.ts index a6c66ec5b..ee02e4577 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -14,6 +14,7 @@ const STEPS: Record< environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), + 'pair-telegram': () => import('./pair-telegram.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), 'signal-auth': () => import('./signal-auth.js'), @@ -23,14 +24,6 @@ const STEPS: Record< onecli: () => import('./onecli.js'), auth: () => import('./auth.js'), 'cli-agent': () => import('./cli-agent.js'), - 'migrate-detect': () => import('./migrate-v1/detect.js'), - 'migrate-validate': () => import('./migrate-v1/validate.js'), - 'migrate-db': () => import('./migrate-v1/db.js'), - 'migrate-groups': () => import('./migrate-v1/groups.js'), - 'migrate-env': () => import('./migrate-v1/env.js'), - 'migrate-channel-auth': () => import('./migrate-v1/channel-auth.js'), - 'migrate-channels': () => import('./migrate-v1/channels.js'), - 'migrate-tasks': () => import('./migrate-v1/tasks.js'), }; async function main(): Promise { diff --git a/setup/migrate-v1.ts b/setup/migrate-v1.ts deleted file mode 100644 index a3c188347..000000000 --- a/setup/migrate-v1.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * 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= - // 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'; -} diff --git a/setup/migrate-v1/channel-auth.ts b/setup/migrate-v1/channel-auth.ts deleted file mode 100644 index 8c8a415be..000000000 --- a/setup/migrate-v1/channel-auth.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Step: migrate-channel-auth - * - * For each channel detected in migrate-db, copy non-.env auth state from v1 - * to the matching v2 location. Env keys are handled by migrate-env (this - * step reads the registry to confirm they made it over, but doesn't rewrite - * them). Files are copied from the first matching candidate path in the - * registry — missing paths are recorded so the skill can prompt the user. - * - * Destination uses the same relative path on v2 (e.g. v1 has - * `data/sessions/baileys/` → v2 gets `data/sessions/baileys/`). If v2 already - * has a different file/dir at that path, we skip and flag it — never clobber. - */ -import fs from 'fs'; -import path from 'path'; - -import { emitStatus } from '../status.js'; -import { - CHANNEL_AUTH_REGISTRY, - autoResolveV2Keys, - readHandoff, - recordStep, - v1PathsFor, - writeHandoff, -} from './shared.js'; - -/** - * Copy file or directory tree from src to dst. `force: false` means existing - * files on the v2 side are never clobbered — important because we'd otherwise - * overwrite auth state the user may have set up on v2 directly. Returns a - * rough count of files copied (post-hoc walk of the destination). - */ -function copyRecursive(src: string, dst: string): number { - if (!fs.existsSync(src)) return 0; - fs.mkdirSync(path.dirname(dst), { recursive: true }); - fs.cpSync(src, dst, { recursive: true, force: false, errorOnExist: false }); - return countFilesUnder(dst); -} - -function countFilesUnder(p: string): number { - if (!fs.existsSync(p)) return 0; - if (fs.statSync(p).isFile()) return 1; - let n = 0; - for (const entry of fs.readdirSync(p, { withFileTypes: true })) { - n += countFilesUnder(path.join(p, entry.name)); - } - return n; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-channel-auth', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const channels = h.detected_channels; - if (channels.length === 0) { - recordStep('migrate-channel-auth', { - status: 'skipped', - fields: { REASON: 'no-channels-detected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNEL_AUTH', { STATUS: 'skipped', REASON: 'no_channels' }); - return; - } - - const v1Paths = v1PathsFor(h.v1_path); - const v1Env = fs.existsSync(v1Paths.env) ? fs.readFileSync(v1Paths.env, 'utf-8') : ''; - const v1EnvKeys = new Set( - v1Env - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map((line) => line.split('=')[0].trim()) - .filter(Boolean), - ); - - const results: typeof h.channel_auth = []; - const followups: string[] = []; - let anyMissingRequired = false; - - for (const ch of channels) { - const spec = CHANNEL_AUTH_REGISTRY[ch.channel_type]; - if (!spec) { - // Unknown channel — give the skill enough context to drive a useful - // interview instead of a generic "we don't know." Scan v1's .env for - // keys that look related (substring match on channel name + common - // suffixes) and list v1 state directories the user should check. - const haystack = ch.channel_type.toLowerCase(); - const candidateEnvKeys = [...v1EnvKeys].filter((k) => { - const lk = k.toLowerCase(); - return ( - lk.includes(haystack) || - (haystack.length >= 3 && lk.includes(haystack.slice(0, 3))) - ); - }); - const v1DataDirs = ['data', 'store', 'data/sessions'] - .map((d) => path.join(h.v1_path, d)) - .filter((p) => fs.existsSync(p)); - - results.push({ - channel_type: ch.channel_type, - env_keys_copied: [], - files_copied: [], - files_missing: [], - notes: `Unknown channel (not in CHANNEL_AUTH_REGISTRY). Inferred via ${ch.source}. Candidate v1 env keys: ${candidateEnvKeys.join(', ') || 'none found'}. Check v1 dirs: ${v1DataDirs.join(', ') || '(none)'}.`, - }); - followups.push( - `Channel "${ch.channel_type}" (${ch.group_count} group(s), inferred via ${ch.source}) is not in the auth registry. ` + - `Candidate v1 env keys that may belong to it: ${candidateEnvKeys.length > 0 ? candidateEnvKeys.join(', ') : '(none obvious)'}. ` + - `Check v1 for on-disk auth state under ${v1DataDirs.join(', ') || '(no standard dirs found)'}. ` + - `The skill should interview the user, then add a registry entry to setup/migrate-v1/shared.ts for future migrations.`, - ); - continue; - } - - const envKeysPresentInV1 = spec.v1EnvKeys.filter((key) => v1EnvKeys.has(key)); - - // Check v2's .env for required keys the v2 adapter needs to boot. v1 - // may not have had all of them (e.g. v1's Discord used discord.js - // directly and never stored DISCORD_PUBLIC_KEY which v2's Chat SDK - // requires). Try to auto-resolve the gap by calling the channel's API - // with the v1 credential; fall through to a followup for anything we - // can't resolve. - const v2EnvPath = path.join(process.cwd(), '.env'); - const v1EnvMap = new Map(); - for (const line of v1Env.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq <= 0) continue; - v1EnvMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); - } - - // Also let the resolver reach into v2's .env (migrate-env already merged - // v1 keys into v2). Either source is fine for derivation inputs. - const v2EnvPre = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2EnvPreMap = new Map(); - for (const line of v2EnvPre.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq <= 0) continue; - v2EnvPreMap.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1)); - } - - const resolved = await autoResolveV2Keys( - ch.channel_type, - (key) => v1EnvMap.get(key) ?? v2EnvPreMap.get(key), - ); - const resolvedKeys = Object.keys(resolved); - if (resolvedKeys.length > 0) { - // Append to v2 .env (never overwriting existing values) + sync the - // container-side copy. Log keys, never values. - let text = v2EnvPre; - if (text && !text.endsWith('\n')) text += '\n'; - for (const [key, value] of Object.entries(resolved)) { - if (v2EnvPreMap.has(key)) continue; - text += `${key}=${value}\n`; - } - fs.writeFileSync(v2EnvPath, text); - try { - const containerEnvDir = path.join(process.cwd(), 'data', 'env'); - fs.mkdirSync(containerEnvDir, { recursive: true }); - fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); - } catch { - // Best-effort; service restart rehydrates it if needed. - } - } - - // Re-read v2 .env after possible resolution to compute the real gap. - const v2Env = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2EnvKeys = new Set( - v2Env - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')) - .map((l) => l.split('=')[0].trim()) - .filter(Boolean), - ); - const missingRequired = spec.requiredV2Keys.filter((r) => !v2EnvKeys.has(r.key)); - if (missingRequired.length > 0) { - anyMissingRequired = true; - followups.push( - `Channel "${ch.channel_type}" is missing required v2 keys in .env: ${missingRequired - .map((r) => `${r.key} (${r.where})`) - .join('; ')}. The v2 adapter won't boot until these are set.`, - ); - } - - const filesCopied: string[] = []; - const filesMissing: string[] = []; - - for (const relPath of spec.candidatePaths) { - const src = path.join(h.v1_path, relPath); - if (!fs.existsSync(src)) continue; - - const dst = path.join(process.cwd(), relPath); - if (fs.existsSync(dst)) { - followups.push( - `Channel "${ch.channel_type}": v2 already has ${relPath} — left untouched. Reconcile manually if needed.`, - ); - filesMissing.push(`${relPath} (already exists in v2)`); - continue; - } - - try { - const count = copyRecursive(src, dst); - filesCopied.push(`${relPath} (${count} files)`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - filesMissing.push(`${relPath} (copy failed: ${message})`); - followups.push(`Channel "${ch.channel_type}": failed to copy ${relPath} — ${message}`); - } - } - - if (spec.candidatePaths.length > 0 && filesCopied.length === 0) { - filesMissing.push(`(no candidate paths existed under ${h.v1_path})`); - } - - results.push({ - channel_type: ch.channel_type, - env_keys_copied: [...envKeysPresentInV1, ...resolvedKeys.map((k) => `${k} (auto-resolved)`)], - files_copied: filesCopied, - files_missing: filesMissing, - notes: spec.note ?? '', - }); - } - - const handoffAfter = readHandoff(); - handoffAfter.channel_auth = results; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const anyFileMissing = results.some((r) => r.files_missing.length > 0); - const anyPartial = anyFileMissing || anyMissingRequired; - recordStep('migrate-channel-auth', { - status: anyPartial ? 'partial' : 'success', - fields: { - CHANNELS: channels.map((c) => c.channel_type).join(','), - FILES_COPIED: results.reduce((sum, r) => sum + r.files_copied.length, 0), - FILES_MISSING: results.reduce((sum, r) => sum + r.files_missing.length, 0), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_CHANNEL_AUTH', { - STATUS: anyPartial ? 'partial' : 'success', - CHANNELS: channels.map((c) => c.channel_type).join(','), - FILES_COPIED: String(results.reduce((sum, r) => sum + r.files_copied.length, 0)), - FILES_MISSING: String(results.reduce((sum, r) => sum + r.files_missing.length, 0)), - }); -} diff --git a/setup/migrate-v1/channels.ts b/setup/migrate-v1/channels.ts deleted file mode 100644 index 89df96658..000000000 --- a/setup/migrate-v1/channels.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Step: migrate-channels - * - * For each channel detected in migrate-db, run the corresponding v2 - * `setup/install-.sh` script in non-interactive mode. The script - * copies the adapter from the `channels` branch, installs the pinned - * dependency, and rebuilds. Credentials in v2 `.env` (migrate-env already - * copied them) are picked up automatically on the next service restart. - * - * This step does NOT run the pairing flow for each channel (that needs - * interactive prompts). The user is guided through pairing by the normal - * channel-selection step in setup/auto.ts, which happens immediately after - * migration. Installing the adapter first means that step won't have to - * re-install. - * - * Channels not supported in v2 are recorded in the handoff as - * `not_supported` so the skill can raise them with the user. - */ -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - installScriptForChannel, - readHandoff, - recordStep, - writeHandoff, -} from './shared.js'; - -function runScript(script: string): Promise<{ code: number; stdout: string; stderr: string }> { - return new Promise((resolve) => { - const child = spawn('bash', [script], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, MIGRATION_NONINTERACTIVE: '1' }, - }); - // Capture both streams silently — the parent is under a clack spinner, - // and forwarding to stdout/stderr would break the spinner UI. The full - // transcript still lands in this step's raw log via the parent's tee - // (runner.ts: spawnStep writes this step's stdout/stderr to logs/setup- - // steps/NN-migrate-channels.log already). - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (c: Buffer) => { - stdout += c.toString('utf-8'); - }); - child.stderr.on('data', (c: Buffer) => { - stderr += c.toString('utf-8'); - }); - child.on('close', (code) => - resolve({ code: code ?? 1, stdout, stderr }), - ); - child.on('error', () => - resolve({ code: 1, stdout, stderr: stderr || 'spawn_error' }), - ); - }); -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-channels', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const channels = h.detected_channels; - if (channels.length === 0) { - recordStep('migrate-channels', { - status: 'skipped', - fields: { REASON: 'no-channels-detected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_CHANNELS', { STATUS: 'skipped', REASON: 'no_channels' }); - return; - } - - const results: typeof h.channels_installed = []; - const followups: string[] = []; - - for (const ch of channels) { - const script = installScriptForChannel(ch.channel_type); - if (!script) { - results.push({ - channel_type: ch.channel_type, - status: 'not_supported', - }); - followups.push( - `Channel "${ch.channel_type}" has no v2 install script. The /migrate-from-v1 skill should ask the user whether to keep it as an orphan messaging_group or drop it.`, - ); - continue; - } - - const absoluteScript = path.join(process.cwd(), script); - if (!fs.existsSync(absoluteScript)) { - results.push({ - channel_type: ch.channel_type, - status: 'failed', - error: `install script missing at ${script}`, - }); - followups.push(`Install script for "${ch.channel_type}" missing at ${script} — this is a v2 repo issue, not a user issue.`); - continue; - } - - log.info('Running channel install script', { channel: ch.channel_type, script: absoluteScript }); - const { code, stdout, stderr } = await runScript(absoluteScript); - // Persist the install-script output to a sidecar so the skill can read it - // if diagnosis is needed. The parent's tee already captures our own - // stdout/stderr but the nested script's output is lost otherwise. - try { - const sidecar = path.join( - process.cwd(), - 'logs', - 'setup-migration', - `install-${ch.channel_type}.log`, - ); - fs.mkdirSync(path.dirname(sidecar), { recursive: true }); - fs.writeFileSync(sidecar, `# ${script}\n# exit ${code}\n\n=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}\n`); - } catch { - // Sidecar is diagnostic-only — don't abort if the log dir is unwritable. - } - if (code === 0) { - results.push({ channel_type: ch.channel_type, status: 'success' }); - } else { - results.push({ - channel_type: ch.channel_type, - status: 'failed', - error: stderr.trim().slice(0, 400) || `exit ${code}`, - }); - followups.push( - `Installing "${ch.channel_type}" failed (exit ${code}). The /migrate-from-v1 skill should retry ${script} or walk the user through /add-${ch.channel_type}.`, - ); - } - } - - const handoffAfter = readHandoff(); - handoffAfter.channels_installed = results; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - // `not_supported` is an expected/known outcome for channels whose v1 adapter - // has no v2 equivalent yet. It's a followup for the skill to raise — not a - // partial success. Only real install failures degrade status. - const anyFailed = results.some((r) => r.status === 'failed'); - const status: 'success' | 'partial' | 'failed' = anyFailed ? 'partial' : 'success'; - - recordStep('migrate-channels', { - status, - fields: { - INSTALLED: results.filter((r) => r.status === 'success').length, - FAILED: results.filter((r) => r.status === 'failed').length, - NOT_SUPPORTED: results.filter((r) => r.status === 'not_supported').length, - CHANNELS: results.map((r) => `${r.channel_type}=${r.status}`).join(','), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_CHANNELS', { - STATUS: status, - INSTALLED: String(results.filter((r) => r.status === 'success').length), - FAILED: String(results.filter((r) => r.status === 'failed').length), - NOT_SUPPORTED: String(results.filter((r) => r.status === 'not_supported').length), - }); -} diff --git a/setup/migrate-v1/db.ts b/setup/migrate-v1/db.ts deleted file mode 100644 index d338214f0..000000000 --- a/setup/migrate-v1/db.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Step: migrate-db - * - * Seed v2.db with the essentials derived from v1's `registered_groups`: - * - agent_groups: one per v1 folder the user selected - * - messaging_groups: one per distinct (channel_type, platform_id) pair - * - messaging_group_agents: the wiring between them, with engage fields - * backfilled from v1's trigger_pattern / requires_trigger - * - * Does NOT seed users, user_roles, or agent_group_members. v1 has no ground - * truth for them — the /migrate-from-v1 skill interviews the user for the - * owner and seeds those tables. - * - * Idempotent: re-running skips any (folder) agent_group, (channel, platform_id) - * messaging_group, and (mg, ag) wiring that already exist. Safe to re-run - * after a partial failure. - * - * Expects `--selection ` where mode is 'all' | 'wired-only'. The - * orchestrator asks the user via clack and passes the result. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { DATA_DIR } from '../../src/config.js'; -import { createAgentGroup, getAgentGroupByFolder } from '../../src/db/agent-groups.js'; -import { initDb } from '../../src/db/connection.js'; -import { - createMessagingGroup, - createMessagingGroupAgent, - getMessagingGroupAgentByPair, - getMessagingGroupByPlatform, -} from '../../src/db/messaging-groups.js'; -import { runMigrations } from '../../src/db/migrations/index.js'; -import { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - fetchBotGuilds, - generateId, - inferChannelType, - readHandoff, - recordStep, - triggerToEngage, - v1PathsFor, - v2PlatformId, - writeHandoff, -} from './shared.js'; - -interface V1Group { - jid: string; - name: string; - folder: string; - trigger_pattern: string | null; - requires_trigger: number | null; - is_main: number | null; - channel_name: string | null; -} - -interface DbArgs { - selection: 'all' | 'wired-only'; -} - -function parseArgs(args: string[]): DbArgs { - let selection: 'all' | 'wired-only' = 'wired-only'; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--selection') { - const v = args[++i]; - if (v === 'all' || v === 'wired-only') selection = v; - } - } - return { selection }; -} - -export async function run(args: string[]): Promise { - const parsed = parseArgs(args); - const h = readHandoff(); - - if (!h.v1_path) { - recordStep('migrate-db', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const validate = h.steps['migrate-validate']; - if (validate && validate.status === 'failed') { - recordStep('migrate-db', { - status: 'skipped', - fields: { REASON: 'validate-failed' }, - notes: ['DB shape did not validate; skipping DB migration.'], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'skipped', REASON: 'validate_failed' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - let v1Db: Database.Database; - try { - v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-db', { - status: 'failed', - fields: { REASON: 'v1-db-open-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_DB', { STATUS: 'failed', REASON: 'v1_db_open_failed', ERROR: message }); - return; - } - - const v1Groups = v1Db - .prepare( - 'SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main, channel_name FROM registered_groups', - ) - .all() as V1Group[]; - v1Db.close(); - - // Filter by selection mode. "wired-only" keeps rows where we can confidently - // say which channel they belong to — either `channel_name` is set, or the - // JID prefix resolves to a known channel type. - const selected: V1Group[] = []; - const detectedChannels = new Map(); - - for (const g of v1Groups) { - const channelType = inferChannelType(g.jid, g.channel_name); - const source: 'channel_name' | 'jid_prefix' = g.channel_name?.trim() ? 'channel_name' : 'jid_prefix'; - if (!channelType) { - // Can't infer — skip in both modes; the skill raises it with the user. - continue; - } - if (parsed.selection === 'wired-only' && source === 'jid_prefix' && !channelType) { - continue; - } - selected.push(g); - const entry = detectedChannels.get(channelType) ?? { source, count: 0 }; - entry.count += 1; - // Prefer explicit channel_name as the source if any row had it. - if (source === 'channel_name') entry.source = 'channel_name'; - detectedChannels.set(channelType, entry); - } - - h.group_selection = { - mode: parsed.selection, - selected_folders: selected.map((g) => g.folder), - total_v1_groups: v1Groups.length, - wired_v1_groups: selected.length, - }; - h.detected_channels = [...detectedChannels.entries()].map(([channel_type, info]) => ({ - channel_type, - source: info.source, - group_count: info.count, - })); - writeHandoff(h); - - // For channels where v2's platform_id includes a component v1 didn't record - // (Discord's guild id), fetch the bot's guilds up-front. If the bot is in - // a single guild we can splice that id into every platform_id; otherwise - // fall back to the v1-format id (v2's channel-registration flow will repair - // on first message). Done ONCE per channel_type, not per-row, so this is - // cheap regardless of group count. - const v1EnvText = fs.existsSync(paths.env) ? fs.readFileSync(paths.env, 'utf-8') : ''; - const v1EnvMap = new Map(); - for (const line of v1EnvText.split('\n')) { - const t = line.trim(); - if (!t || t.startsWith('#')) continue; - const eq = t.indexOf('='); - if (eq <= 0) continue; - v1EnvMap.set(t.slice(0, eq).trim(), t.slice(eq + 1)); - } - const singleGuildByChannel = new Map(); - for (const channelType of detectedChannels.keys()) { - const info = await fetchBotGuilds(channelType, (k) => v1EnvMap.get(k)); - if (info && info.guildIds.length === 1) { - singleGuildByChannel.set(channelType, info.guildIds[0]); - } - } - - // Initialize v2.db (creates schema if not present — runMigrations is no-op - // when the schema is already current, so this is safe on a live v2 install). - fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true }); - const v2Path = path.join(DATA_DIR, 'v2.db'); - const v2Db = initDb(v2Path); - runMigrations(v2Db); - - let agentGroupsCreated = 0; - let agentGroupsReused = 0; - let messagingGroupsCreated = 0; - let messagingGroupsReused = 0; - let wiringsCreated = 0; - let wiringsReused = 0; - let skipped = 0; - const followups: string[] = []; - - for (const g of selected) { - const channelType = inferChannelType(g.jid, g.channel_name); - if (!channelType) { - skipped += 1; - continue; - } - - const guildId = singleGuildByChannel.get(channelType); - const platformId = v2PlatformId(channelType, g.jid, { guildId }); - const createdAt = new Date().toISOString(); - - try { - // agent_group — one per folder - let ag = getAgentGroupByFolder(g.folder); - if (!ag) { - createAgentGroup({ - id: generateId('ag'), - name: g.name || g.folder, - folder: g.folder, - agent_provider: null, - created_at: createdAt, - }); - ag = getAgentGroupByFolder(g.folder)!; - agentGroupsCreated += 1; - } else { - agentGroupsReused += 1; - } - - // messaging_group — one per (channel_type, platform_id) - let mg = getMessagingGroupByPlatform(channelType, platformId); - if (!mg) { - createMessagingGroup({ - id: generateId('mg'), - channel_type: channelType, - platform_id: platformId, - name: g.name || null, - is_group: 1, // v1 didn't distinguish; default to group (safe for routing) - unknown_sender_policy: 'strict', // skill's interview flips this if v1 was "public" - created_at: createdAt, - }); - mg = getMessagingGroupByPlatform(channelType, platformId)!; - messagingGroupsCreated += 1; - } else { - messagingGroupsReused += 1; - } - - // messaging_group_agents — wire them if not already wired - const existingWiring = getMessagingGroupAgentByPair(mg.id, ag.id); - if (!existingWiring) { - const engage = triggerToEngage({ - trigger_pattern: g.trigger_pattern, - requires_trigger: g.requires_trigger, - }); - createMessagingGroupAgent({ - id: generateId('mga'), - messaging_group_id: mg.id, - agent_group_id: ag.id, - engage_mode: engage.engage_mode, - engage_pattern: engage.engage_pattern, - sender_scope: 'all', - ignored_message_policy: 'drop', - session_mode: 'shared', - priority: 0, - created_at: createdAt, - }); - wiringsCreated += 1; - } else { - wiringsReused += 1; - } - - if (g.is_main === 1) { - followups.push( - `Folder "${g.folder}" was the v1 main group (is_main=1). v2 has no is_main flag — the /migrate-from-v1 skill should grant this folder's channel to the owner user when it runs.`, - ); - } - } catch (err) { - skipped += 1; - const message = err instanceof Error ? err.message : String(err); - log.error('Failed to seed v1 group', { folder: g.folder, err: message }); - followups.push(`Folder "${g.folder}" failed to seed: ${message}`); - } - } - - v2Db.close(); - - const partial = skipped > 0; - const handoffAfter = readHandoff(); - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - recordStep('migrate-db', { - status: partial ? 'partial' : 'success', - fields: { - SELECTION: parsed.selection, - V1_GROUPS_TOTAL: v1Groups.length, - SELECTED: selected.length, - AGENT_GROUPS_CREATED: agentGroupsCreated, - AGENT_GROUPS_REUSED: agentGroupsReused, - MESSAGING_GROUPS_CREATED: messagingGroupsCreated, - MESSAGING_GROUPS_REUSED: messagingGroupsReused, - WIRINGS_CREATED: wiringsCreated, - WIRINGS_REUSED: wiringsReused, - SKIPPED: skipped, - CHANNELS: [...detectedChannels.keys()].join(','), - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_DB', { - STATUS: partial ? 'partial' : 'success', - SELECTION: parsed.selection, - V1_GROUPS_TOTAL: String(v1Groups.length), - SELECTED: String(selected.length), - AGENT_GROUPS_CREATED: String(agentGroupsCreated), - MESSAGING_GROUPS_CREATED: String(messagingGroupsCreated), - WIRINGS_CREATED: String(wiringsCreated), - SKIPPED: String(skipped), - CHANNELS: [...detectedChannels.keys()].join(',') || 'none', - }); -} diff --git a/setup/migrate-v1/detect.ts b/setup/migrate-v1/detect.ts deleted file mode 100644 index 983531d9b..000000000 --- a/setup/migrate-v1/detect.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * 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 { - 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), - }); -} diff --git a/setup/migrate-v1/env.ts b/setup/migrate-v1/env.ts deleted file mode 100644 index e2530e026..000000000 --- a/setup/migrate-v1/env.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Step: migrate-env - * - * Copy every key from v1 `.env` to v2 `.env`. Preserves v2 values that - * already exist (never overwrites). Skips lines that don't look like a - * `KEY=value` pair. - * - * Why copy everything, not a curated list? v1 installs accumulate - * project-specific keys (custom MCP creds, feature flags, webhook tokens) - * that the migration can't enumerate ahead of time. The user explicitly - * asked for everything. We log what we carried so the skill can review. - * - * Security note: we do NOT log values here — only keys. The raw log already - * contains the file contents; we don't echo them to stdout. - */ -import fs from 'fs'; -import path from 'path'; - -import { emitStatus } from '../status.js'; -import { readHandoff, recordStep, v1PathsFor } from './shared.js'; - -interface EnvLine { - key: string; - value: string; - raw: string; -} - -function parseEnv(text: string): EnvLine[] { - const out: EnvLine[] = []; - for (const raw of text.split('\n')) { - const line = raw.trimEnd(); - if (!line) continue; - if (line.startsWith('#')) continue; - const eq = line.indexOf('='); - if (eq <= 0) continue; - const key = line.slice(0, eq).trim(); - if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue; - const value = line.slice(eq + 1); - out.push({ key, value, raw: line }); - } - return out; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-env', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - if (!fs.existsSync(paths.env)) { - recordStep('migrate-env', { - status: 'skipped', - fields: { REASON: 'v1-env-missing' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_ENV', { STATUS: 'skipped', REASON: 'v1_env_missing' }); - return; - } - - const v2EnvPath = path.join(process.cwd(), '.env'); - const v1Text = fs.readFileSync(paths.env, 'utf-8'); - const v1Lines = parseEnv(v1Text); - - let v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : ''; - const v2Lines = parseEnv(v2Text); - const v2Keys = new Set(v2Lines.map((l) => l.key)); - - const copied: string[] = []; - const skipped: string[] = []; - const appended: string[] = []; - - // Tag the appended block so a later re-run can find it and not double-append. - const BLOCK_START = '# ── migrated from v1 ──'; - const alreadyMigrated = v2Text.includes(BLOCK_START); - - for (const line of v1Lines) { - if (v2Keys.has(line.key)) { - skipped.push(line.key); - continue; - } - copied.push(line.key); - appended.push(line.raw); - } - - if (appended.length > 0) { - const suffix = [ - v2Text.endsWith('\n') || v2Text === '' ? '' : '\n', - alreadyMigrated ? '' : `\n${BLOCK_START}\n`, - appended.join('\n'), - '\n', - ].join(''); - v2Text = v2Text + suffix; - fs.writeFileSync(v2EnvPath, v2Text); - } - - // Container reads from data/env/env (host mounts it). Keep it in sync. - const containerEnvDir = path.join(process.cwd(), 'data', 'env'); - try { - fs.mkdirSync(containerEnvDir, { recursive: true }); - fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env')); - } catch { - // Non-fatal; the service restart (later step) will rehydrate if needed. - } - - recordStep('migrate-env', { - status: 'success', - fields: { - KEYS_COPIED: copied.length, - KEYS_SKIPPED_EXISTING: skipped.length, - V1_ENV: paths.env, - V2_ENV: v2EnvPath, - }, - notes: [ - copied.length > 0 ? `Copied: ${copied.join(', ')}` : '', - skipped.length > 0 ? `Skipped (already in v2 .env): ${skipped.join(', ')}` : '', - ].filter(Boolean), - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_ENV', { - STATUS: 'success', - KEYS_COPIED: String(copied.length), - KEYS_SKIPPED_EXISTING: String(skipped.length), - COPIED_KEYS: copied.join(',') || 'none', - }); -} diff --git a/setup/migrate-v1/groups.ts b/setup/migrate-v1/groups.ts deleted file mode 100644 index 206f44128..000000000 --- a/setup/migrate-v1/groups.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Step: migrate-groups - * - * Copy v1 group folders into v2. For each folder selected in migrate-db: - * - Create groups// in v2 if missing - * - Copy v1's CLAUDE.md to v2 as CLAUDE.local.md (v2 composes CLAUDE.md at - * container spawn — don't write directly to CLAUDE.md) - * - If v1 had a container_config JSON, write it to .v1-container-config.json - * for the /migrate-from-v1 skill to reconcile (v2's container.json shape - * has drifted enough that a silent 1:1 copy would be wrong) - * - Preserve any other non-standard files from the v1 folder (e.g. SOUL.md, - * personality.md, custom subdirs) — rsync-style, skipping destination files - * that already exist. - * - * Does not overwrite files already present in v2 — re-running is safe. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { log } from '../../src/log.js'; -import { emitStatus } from '../status.js'; -import { - readHandoff, - recordStep, - safeJsonStringify, - scanForV1Patterns, - v1PathsFor, - writeHandoff, -} from './shared.js'; - -const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']); - -/** - * Copy everything in src except SKIP_NAMES. CLAUDE.md is handled separately. - * Returns the count of files actually written (skipped-existing not counted). - */ -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; - } - // Don't clobber files v2 already has (e.g. CLAUDE.local.md that the - // operator already wrote). Append-only semantics for this step. - if (fs.existsSync(d)) continue; - fs.copyFileSync(s, d); - written += 1; - } - return written; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-groups', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - if (h.group_selection.selected_folders.length === 0) { - recordStep('migrate-groups', { - status: 'skipped', - fields: { REASON: 'no-folders-selected' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_GROUPS', { STATUS: 'skipped', REASON: 'no_selection' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - const v2GroupsDir = path.join(process.cwd(), 'groups'); - fs.mkdirSync(v2GroupsDir, { recursive: true }); - - // Pull container_config for each selected folder up-front so we can write - // the .v1-container-config.json sidecar without holding the DB open per-folder. - const containerConfigs = new Map(); - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const rows = v1Db - .prepare('SELECT folder, container_config FROM registered_groups WHERE folder IN (SELECT value FROM json_each(?))') - .all(JSON.stringify(h.group_selection.selected_folders)) as Array<{ folder: string; container_config: string | null }>; - for (const r of rows) containerConfigs.set(r.folder, r.container_config); - v1Db.close(); - } catch (err) { - // Older sqlite without json_each would break the query. Fall back to - // per-folder reads — slower but reliable. - log.info('Falling back to per-folder container_config lookup', { err }); - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const stmt = v1Db.prepare('SELECT container_config FROM registered_groups WHERE folder = ?'); - for (const folder of h.group_selection.selected_folders) { - const row = stmt.get(folder) as { container_config: string | null } | undefined; - containerConfigs.set(folder, row?.container_config ?? null); - } - v1Db.close(); - } catch { - // Give up — we still migrate files; the skill handles missing config. - } - } - - let foldersProcessed = 0; - let foldersSkippedMissing = 0; - let claudeMdMigrated = 0; - let claudeLocalPreserved = 0; - let containerConfigsStashed = 0; - let otherFilesCopied = 0; - const followups: string[] = []; - - for (const folder of h.group_selection.selected_folders) { - const v1Folder = path.join(paths.groups, folder); - const v2Folder = path.join(v2GroupsDir, folder); - - if (!fs.existsSync(v1Folder)) { - foldersSkippedMissing += 1; - followups.push( - `Folder "${folder}" was in v1's registered_groups but not on disk at ${v1Folder} — DB entry was seeded, no files to migrate.`, - ); - continue; - } - - fs.mkdirSync(v2Folder, { recursive: true }); - - // CLAUDE.md → CLAUDE.local.md. Don't write CLAUDE.md directly — v2's - // group-init.ts composes that file from shared + fragments + local. - const v1Claude = path.join(v1Folder, 'CLAUDE.md'); - const v2Local = path.join(v2Folder, 'CLAUDE.local.md'); - let claudeContent: string | null = null; - if (fs.existsSync(v1Claude)) { - if (fs.existsSync(v2Local)) { - claudeLocalPreserved += 1; - try { - claudeContent = fs.readFileSync(v2Local, 'utf-8'); - } catch { - claudeContent = null; - } - } else { - try { - claudeContent = fs.readFileSync(v1Claude, 'utf-8'); - fs.writeFileSync(v2Local, claudeContent); - claudeMdMigrated += 1; - } catch (err) { - followups.push(`Failed to copy CLAUDE.md for "${folder}": ${err instanceof Error ? err.message : err}`); - } - } - } - - // Scan the copied content for v1-specific infrastructure patterns. If we - // find any, add a followup so the /migrate-from-v1 skill can triage the - // file with the user. We DON'T edit the file — v1 CLAUDE.md can be - // author-specific and heuristic translation is worse than a flag. - if (claudeContent) { - const matches = scanForV1Patterns(claudeContent); - if (matches.length > 0) { - const summary = matches - .map((m) => `${m.description} (lines ${m.lines.join(',')})`) - .join('; '); - followups.push( - `Folder "${folder}" CLAUDE.local.md references v1-specific infrastructure: ${summary}. The skill should read the file and translate patterns using docs/v1-to-v2-changes.md.`, - ); - } - } - - // Stash container_config JSON so the skill can reconcile it. - const config = containerConfigs.get(folder); - if (config) { - const sidecar = path.join(v2Folder, '.v1-container-config.json'); - try { - // Pretty-print so humans can read it during reconciliation. - const parsed = JSON.parse(config) as unknown; - fs.writeFileSync(sidecar, safeJsonStringify(parsed)); - containerConfigsStashed += 1; - followups.push( - `Folder "${folder}" has a v1 container_config — stashed at ${path.relative(process.cwd(), sidecar)}. The /migrate-from-v1 skill will map it to v2's container.json shape.`, - ); - } catch { - // Non-JSON container_config — write raw so the skill can still read it. - fs.writeFileSync(sidecar, config); - containerConfigsStashed += 1; - } - } - - otherFilesCopied += copyTree(v1Folder, v2Folder); - foldersProcessed += 1; - } - - // Merge followups. - const handoffAfter = readHandoff(); - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const partial = foldersSkippedMissing > 0; - recordStep('migrate-groups', { - status: partial ? 'partial' : 'success', - fields: { - FOLDERS_PROCESSED: foldersProcessed, - FOLDERS_SKIPPED_MISSING: foldersSkippedMissing, - CLAUDE_MD_MIGRATED: claudeMdMigrated, - CLAUDE_LOCAL_PRESERVED: claudeLocalPreserved, - CONTAINER_CONFIGS_STASHED: containerConfigsStashed, - OTHER_FILES_COPIED: otherFilesCopied, - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_GROUPS', { - STATUS: partial ? 'partial' : 'success', - FOLDERS_PROCESSED: String(foldersProcessed), - FOLDERS_SKIPPED_MISSING: String(foldersSkippedMissing), - CLAUDE_MD_MIGRATED: String(claudeMdMigrated), - CONTAINER_CONFIGS_STASHED: String(containerConfigsStashed), - OTHER_FILES_COPIED: String(otherFilesCopied), - }); -} diff --git a/setup/migrate-v1/tasks.ts b/setup/migrate-v1/tasks.ts deleted file mode 100644 index 836be7c5b..000000000 --- a/setup/migrate-v1/tasks.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Step: migrate-tasks - * - * Port v1's `scheduled_tasks` into v2's session inbound DBs. v1 had a - * dedicated table with its own scheduling grammar; v2 treats tasks as - * `messages_in` rows with `kind='task'`, `process_after`, and `recurrence` - * (cron string). See docs/v1-to-v2-changes.md "Scheduling". - * - * Flow per v1 row: - * 1. Resolve (agent_group_id, messaging_group_id) from v1 (group_folder, chat_jid) - * 2. resolveSession() — creates the session on demand if absent - * 3. insertTask() into the session's inbound.db - * - * Active v1 rows (status='active') are migrated. Completed/stopped rows get - * exported to logs/setup-migration/inactive-tasks.json for reference. - * - * v1's schedule_type / schedule_value are mapped to cron here. Known types: - * 'cron' → schedule_value is already a cron string - * 'interval' → e.g. '5m'/'1h' → cron equivalent (best effort) - * 'once' → no recurrence, process_after = schedule_value if parseable - * Unknown types go to inactive-tasks.json with a note. - */ -import fs from 'fs'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { DATA_DIR } from '../../src/config.js'; -import { initDb, closeDb } from '../../src/db/connection.js'; -import { getAgentGroupByFolder } from '../../src/db/agent-groups.js'; -import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js'; -import { runMigrations } from '../../src/db/migrations/index.js'; -import { log } from '../../src/log.js'; -import { insertTask } from '../../src/modules/scheduling/db.js'; -import { openInboundDb, resolveSession } from '../../src/session-manager.js'; -import { emitStatus } from '../status.js'; -import { - INACTIVE_TASKS_PATH, - MIGRATION_DIR, - inferChannelType, - readHandoff, - recordStep, - safeJsonStringify, - v1PathsFor, - v2PlatformId, - writeHandoff, -} from './shared.js'; - -interface V1Task { - id: string; - group_folder: string; - chat_jid: string; - prompt: string; - schedule_type: string; - schedule_value: string; - next_run: string | null; - last_run: string | null; - status: string; - context_mode: string | null; - script: string | null; -} - -/** Convert v1 schedule_type + schedule_value into (processAfter, recurrence). */ -function toProcessAfterAndRecurrence(t: V1Task): { - processAfter: string; - recurrence: string | null; - note?: string; -} | null { - const now = new Date().toISOString(); - - if (t.schedule_type === 'cron') { - // Validate shape — 5 or 6 fields separated by whitespace. cron-parser is - // the runtime source of truth; here we just reject obvious garbage so - // we don't insert tasks that will explode on the first sweep tick. - const fields = t.schedule_value.trim().split(/\s+/).length; - if (fields < 5 || fields > 6) return null; - return { - processAfter: t.next_run || now, - recurrence: t.schedule_value.trim(), - }; - } - - if (t.schedule_type === 'interval') { - // '5m' → '*/5 * * * *'; '1h' → '0 * * * *'; '1d' → '0 0 * * *'. - // Best effort — any unit we don't recognize falls through to null. - const m = /^(\d+)([smhd])$/.exec(t.schedule_value.trim()); - if (!m) return null; - const n = parseInt(m[1], 10); - const unit = m[2]; - if (!n || n < 1) return null; - let cron: string | null = null; - if (unit === 'm' && n < 60) cron = `*/${n} * * * *`; - else if (unit === 'h' && n < 24) cron = `0 */${n} * * *`; - else if (unit === 'd' && n < 28) cron = `0 0 */${n} * *`; - if (!cron) return null; - return { processAfter: t.next_run || now, recurrence: cron }; - } - - if (t.schedule_type === 'once' || t.schedule_type === 'at') { - return { - processAfter: t.next_run || t.schedule_value || now, - recurrence: null, - }; - } - - return null; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const validate = h.steps['migrate-validate']; - if (validate && validate.status === 'failed') { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'validate-failed' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'validate_failed' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - - // Read v1 tasks into memory so we can close the v1 DB before we open v2's - // central DB via initDb() (which is a module singleton and doesn't love - // having two files open through it). - let activeTasks: V1Task[] = []; - let inactiveTasks: V1Task[] = []; - try { - const v1Db = new Database(paths.db, { readonly: true, fileMustExist: true }); - const all = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[]; - v1Db.close(); - activeTasks = all.filter((t) => t.status === 'active'); - inactiveTasks = all.filter((t) => t.status !== 'active'); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-tasks', { - status: 'failed', - fields: { REASON: 'v1-read-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'failed', REASON: 'v1_read_failed', ERROR: message }); - return; - } - - if (activeTasks.length === 0 && inactiveTasks.length === 0) { - recordStep('migrate-tasks', { - status: 'skipped', - fields: { REASON: 'no-v1-tasks' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_TASKS', { STATUS: 'skipped', REASON: 'no_v1_tasks' }); - return; - } - - // Dump inactive tasks for reference — always, even if there are no active ones. - if (inactiveTasks.length > 0) { - fs.mkdirSync(MIGRATION_DIR, { recursive: true }); - fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); - } - - // Connect to v2 central DB to resolve (folder → ag) and (channel+pid → mg). - const v2Path = path.join(DATA_DIR, 'v2.db'); - fs.mkdirSync(path.dirname(v2Path), { recursive: true }); - const v2Db = initDb(v2Path); - runMigrations(v2Db); - - const followups: string[] = []; - let migrated = 0; - let failed = 0; - let skipped = 0; - - for (const t of activeTasks) { - try { - const ag = getAgentGroupByFolder(t.group_folder); - if (!ag) { - skipped += 1; - followups.push( - `Task "${t.id}" (folder "${t.group_folder}"): agent_group not seeded in v2 — run migrate-db first or deselect the task.`, - ); - continue; - } - - const channelType = inferChannelType(t.chat_jid, null); - if (!channelType) { - skipped += 1; - followups.push(`Task "${t.id}": could not infer channel from chat_jid "${t.chat_jid}".`); - continue; - } - const platformId = v2PlatformId(channelType, t.chat_jid); - const mg = getMessagingGroupByPlatform(channelType, platformId); - if (!mg) { - skipped += 1; - followups.push( - `Task "${t.id}": messaging_group for (${channelType}, ${platformId}) not seeded. Add the channel then re-run this step.`, - ); - continue; - } - - const scheduling = toProcessAfterAndRecurrence(t); - if (!scheduling) { - skipped += 1; - followups.push( - `Task "${t.id}": schedule_type "${t.schedule_type}" / value "${t.schedule_value}" did not map to a v2 cron — exported to inactive-tasks.json for manual review.`, - ); - inactiveTasks.push(t); - continue; - } - - // resolveSession creates (ag, mg) session if not present; 'shared' mode - // matches v1 which had one session per group_folder. - const { session } = resolveSession(ag.id, mg.id, null, 'shared'); - const inboxDb = openInboundDb(ag.id, session.id); - try { - // Idempotence: skip if we've already migrated this task id. We use the - // v1 task id verbatim as the v2 messages_in.id (stable — lets users - // re-run migration without duplicate-key errors or shadow tasks). - const existing = inboxDb - .prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'") - .get(t.id) as { id: string } | undefined; - if (existing) { - skipped += 1; - continue; - } - - insertTask(inboxDb, { - id: t.id, - processAfter: scheduling.processAfter, - recurrence: scheduling.recurrence, - platformId, - channelType, - threadId: null, - content: JSON.stringify({ - prompt: t.prompt, - script: t.script ?? null, - migrated_from_v1: { original_id: t.id, context_mode: t.context_mode ?? null }, - }), - }); - } finally { - inboxDb.close(); - } - - log.info('Migrated v1 scheduled task', { taskId: t.id, session: session.id, mg: mg.id }); - migrated += 1; - } catch (err) { - failed += 1; - const message = err instanceof Error ? err.message : String(err); - followups.push(`Task "${t.id}" failed to migrate: ${message}`); - } - } - - // Re-dump inactive tasks in case scheduling-translation pushed any in. - if (inactiveTasks.length > 0) { - fs.writeFileSync(INACTIVE_TASKS_PATH, safeJsonStringify({ tasks: inactiveTasks })); - } - - closeDb(); - - const handoffAfter = readHandoff(); - handoffAfter.tasks = { - v1_active: activeTasks.length, - v1_inactive: inactiveTasks.length, - migrated, - failed, - skipped, - }; - handoffAfter.followups = [...new Set([...handoffAfter.followups, ...followups])]; - writeHandoff(handoffAfter); - - const partial = failed > 0 || skipped > 0; - recordStep('migrate-tasks', { - status: failed > 0 ? 'partial' : partial ? 'partial' : 'success', - fields: { - V1_ACTIVE: activeTasks.length, - V1_INACTIVE: inactiveTasks.length, - MIGRATED: migrated, - FAILED: failed, - SKIPPED: skipped, - INACTIVE_EXPORT: inactiveTasks.length > 0 ? INACTIVE_TASKS_PATH : '', - }, - notes: followups, - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_TASKS', { - STATUS: partial ? 'partial' : 'success', - V1_ACTIVE: String(activeTasks.length), - V1_INACTIVE: String(inactiveTasks.length), - MIGRATED: String(migrated), - FAILED: String(failed), - SKIPPED: String(skipped), - }); -} diff --git a/setup/migrate-v1/validate.ts b/setup/migrate-v1/validate.ts deleted file mode 100644 index 73cd37711..000000000 --- a/setup/migrate-v1/validate.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Step: migrate-validate - * - * Before touching v1 data, assert the DB has the shape we expect. We know - * v1's schema (see docs/v1-to-v2-changes.md "Entity model") — different - * shapes happened over v1's development, but by v1.2.x the `registered_groups` - * columns and `scheduled_tasks` columns stabilized. If we see something else, - * we bail early so later steps don't write garbage to v2.db. - * - * Output: - * - `logs/setup-migration/schema-mismatch.json` on failure (read by the skill) - * - Status block MIGRATE_VALIDATE with OK/FAILED - * - Even on failure, subsequent steps still run — they'll short-circuit - * on their own if validate marked the DB unusable. This keeps env + group - * folder migration working when only the DB is broken. - */ -import fs from 'fs'; - -import Database from 'better-sqlite3'; - -import { emitStatus } from '../status.js'; -import { - SCHEMA_MISMATCH_PATH, - readHandoff, - recordStep, - safeJsonStringify, - v1PathsFor, -} from './shared.js'; - -const EXPECTED_TABLES = [ - 'registered_groups', - 'scheduled_tasks', - 'chats', - 'messages', - 'sessions', - 'router_state', -]; - -const REQUIRED_REGISTERED_GROUPS_COLUMNS = [ - 'jid', - 'name', - 'folder', - 'trigger_pattern', - 'added_at', - 'requires_trigger', -]; - -const REQUIRED_SCHEDULED_TASKS_COLUMNS = [ - 'id', - 'group_folder', - 'chat_jid', - 'prompt', - 'schedule_type', - 'schedule_value', - 'status', -]; - -interface TableInfo { - table: string; - columns: string[]; - missing_columns: string[]; -} - -export async function run(_args: string[]): Promise { - const h = readHandoff(); - if (!h.v1_path) { - recordStep('migrate-validate', { - status: 'skipped', - fields: { REASON: 'detect-not-run' }, - notes: [], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { STATUS: 'skipped', REASON: 'no_v1_path' }); - return; - } - - const paths = v1PathsFor(h.v1_path); - if (!fs.existsSync(paths.db)) { - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'db-missing', DB_PATH: paths.db }, - notes: ['v1 DB file does not exist at expected path'], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'db_missing', - DB_PATH: paths.db, - }); - return; - } - - let db: Database.Database; - try { - db = new Database(paths.db, { readonly: true, fileMustExist: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'db-open-failed' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'db_open_failed', - ERROR: message, - }); - return; - } - - try { - const tableRows = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() as Array<{ name: string }>; - const tables = new Set(tableRows.map((r) => r.name)); - - const missingTables = EXPECTED_TABLES.filter((t) => !tables.has(t)); - const tableInfos: TableInfo[] = []; - - for (const t of EXPECTED_TABLES) { - if (!tables.has(t)) continue; - const cols = db.prepare(`PRAGMA table_info(${t})`).all() as Array<{ name: string }>; - const columnNames = cols.map((c) => c.name); - const missing = - t === 'registered_groups' - ? REQUIRED_REGISTERED_GROUPS_COLUMNS.filter((c) => !columnNames.includes(c)) - : t === 'scheduled_tasks' - ? REQUIRED_SCHEDULED_TASKS_COLUMNS.filter((c) => !columnNames.includes(c)) - : []; - tableInfos.push({ table: t, columns: columnNames, missing_columns: missing }); - } - - const columnMismatches = tableInfos.filter((t) => t.missing_columns.length > 0); - const groupCount = - tables.has('registered_groups') - ? ((db.prepare('SELECT COUNT(*) AS c FROM registered_groups').get() as { c: number }).c) - : 0; - const taskCount = - tables.has('scheduled_tasks') - ? ((db.prepare('SELECT COUNT(*) AS c FROM scheduled_tasks').get() as { c: number }).c) - : 0; - - db.close(); - - if (missingTables.length > 0 || columnMismatches.length > 0) { - const mismatch = { - v1_path: h.v1_path, - v1_version: h.v1_version, - present_tables: [...tables].sort(), - missing_tables: missingTables, - column_mismatches: columnMismatches, - scanned_at: new Date().toISOString(), - }; - fs.writeFileSync(SCHEMA_MISMATCH_PATH, safeJsonStringify(mismatch)); - - recordStep('migrate-validate', { - status: 'failed', - fields: { - MISSING_TABLES: missingTables.join(',') || 'none', - COLUMN_MISMATCHES: String(columnMismatches.length), - REPORT: SCHEMA_MISMATCH_PATH, - }, - notes: [ - missingTables.length > 0 ? `Missing tables: ${missingTables.join(', ')}` : '', - columnMismatches.length > 0 - ? `Column mismatches in: ${columnMismatches.map((c) => c.table).join(', ')}` - : '', - ].filter(Boolean), - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'schema_mismatch', - MISSING_TABLES: missingTables.join(',') || 'none', - COLUMN_MISMATCHES: String(columnMismatches.length), - REPORT: SCHEMA_MISMATCH_PATH, - }); - return; - } - - recordStep('migrate-validate', { - status: 'success', - fields: { - V1_GROUPS: groupCount, - V1_TASKS: taskCount, - }, - notes: [], - at: new Date().toISOString(), - }); - - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'success', - V1_GROUPS: String(groupCount), - V1_TASKS: String(taskCount), - }); - } catch (err) { - db.close(); - const message = err instanceof Error ? err.message : String(err); - recordStep('migrate-validate', { - status: 'failed', - fields: { REASON: 'validate-error' }, - notes: [message], - at: new Date().toISOString(), - }); - emitStatus('MIGRATE_VALIDATE', { - STATUS: 'failed', - REASON: 'validate_error', - ERROR: message, - }); - } -}