chore: remove old setup-embedded migration steps

The old migration flow (detect → validate → db → groups → env →
channel-auth → channels → tasks) ran inside `bash nanoclaw.sh` via
setup/auto.ts. Replaced by the standalone `bash migrate-v2.sh` flow.

Deleted:
- setup/migrate-v1.ts (orchestrator)
- setup/migrate-v1/{detect,validate,db,env,groups,channel-auth,channels,tasks}.ts

Kept:
- setup/migrate-v1/shared.ts (used by new migrate-v2/ steps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-01 20:20:06 +00:00
parent 1d73b2986a
commit 67eb85d818
11 changed files with 4 additions and 2023 deletions
+3 -11
View File
@@ -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<void> {
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';
+1 -8
View File
@@ -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<void> {
-257
View File
@@ -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=<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';
}
-262
View File
@@ -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<void> {
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<string, string>();
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<string, string>();
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)),
});
}
-172
View File
@@ -1,172 +0,0 @@
/**
* Step: migrate-channels
*
* For each channel detected in migrate-db, run the corresponding v2
* `setup/install-<channel>.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<void> {
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),
});
}
-321
View File
@@ -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 <mode>` 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<void> {
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<string, { source: 'channel_name' | 'jid_prefix'; count: number }>();
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<string, string>();
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<string, string>();
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',
});
}
-107
View File
@@ -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<void> {
const parsed = parseArgs(args);
// An explicit path — either from --path or $NANOCLAW_V1_PATH — is
// authoritative. If it doesn't validate, we don't fall through to
// the default candidate list. That keeps the user's explicit intent
// from being silently overridden.
const envOverride = process.env.NANOCLAW_V1_PATH?.trim();
const explicit = parsed.path ?? envOverride ?? null;
const candidates = explicit ? [explicit] : defaultV1Candidates();
for (const candidate of candidates) {
const absolute = path.resolve(candidate);
// Don't self-match — if the candidate resolves to the v2 checkout we're
// running inside, skip it. Protects users who cloned v2 into `~/nanoclaw`
// after deleting v1.
if (absolute === path.resolve(process.cwd())) continue;
const check = looksLikeV1Install(absolute);
if (!check.ok) continue;
const paths = v1PathsFor(absolute);
let version = 'unknown';
try {
const pkg = JSON.parse(fs.readFileSync(paths.packageJson, 'utf-8')) as { version?: string };
version = pkg.version ?? 'unknown';
} catch {
// Already sanity-checked by looksLikeV1Install — a failure here means
// the file changed under us between calls. Unlikely, not fatal.
}
const h = readHandoff();
h.v1_path = absolute;
h.v1_version = version;
writeHandoff(h);
recordStep('migrate-detect', {
status: 'success',
fields: { V1_PATH: absolute, V1_VERSION: version },
notes: [],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DETECT', {
STATUS: 'success',
V1_PATH: absolute,
V1_VERSION: version,
DB_PATH: paths.db,
ENV_PATH: paths.env,
GROUPS_PATH: paths.groups,
});
return;
}
// Nothing matched. Not an error — most v2 installs are fresh, not migrations.
const scanned = candidates.map((c) => path.resolve(c)).join(',');
recordStep('migrate-detect', {
status: 'skipped',
fields: { REASON: 'no-v1-install-found' },
notes: [`Scanned: ${scanned}`],
at: new Date().toISOString(),
});
emitStatus('MIGRATE_DETECT', {
STATUS: 'skipped',
REASON: 'not_found',
CANDIDATES_SCANNED: String(candidates.length),
});
}
-135
View File
@@ -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<void> {
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',
});
}
-230
View File
@@ -1,230 +0,0 @@
/**
* Step: migrate-groups
*
* Copy v1 group folders into v2. For each folder selected in migrate-db:
* - Create groups/<folder>/ 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<void> {
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<string, string | null>();
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),
});
}
-307
View File
@@ -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<void> {
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),
});
}
-213
View File
@@ -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<void> {
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,
});
}
}