feat: add v1 → v2 migration to setup flow (experimental)
`bash nanoclaw.sh` detects a v1 install before channel pairing and does a best-effort automated port of operationally important state. Hands off to a new `/migrate-from-v1` skill for owner seeding and fork customizations. Between the timezone and channel steps, `setup/auto.ts` calls `runMigrateV1()` which orchestrates these registered sub-steps (each a separate entry in the progression log with its own raw log + status block — failures never abort the chain): - **migrate-detect** — scans siblings of the v2 checkout + common $HOME locations; `$NANOCLAW_V1_PATH` overrides authoritatively. Relaxed `package.json` check lets forks + partial installs still match; DB presence is the strongest signal. - **migrate-validate** — asserts v1 DB shape (tables + required columns); writes `schema-mismatch.json` on failure. Subsequent steps short-circuit their DB-dependent parts but still run. - **migrate-db** — seeds `agent_groups` + `messaging_groups` + `messaging_group_agents` from v1's `registered_groups`. JID decomposition (`dc:123` → `channel_type='discord'`, `platform_id='discord:123'`); `trigger_pattern` + `requires_trigger` → `engage_mode` + `engage_pattern` (mirrors migration 010 backfill). Users + user_roles are NOT seeded — the skill does that with an owner interview. Idempotent: existing rows reused, not duplicated. - **migrate-groups** — rsync group folders. v1 `CLAUDE.md` → v2 `CLAUDE.local.md` (v2 composes `CLAUDE.md` at container spawn); v1 `container_config` JSON → `.v1-container-config.json` sidecar for the skill to translate. Tight v1-pattern scan (`/workspace/ipc/tasks`, `store/messages.db`, `[PR_CONTEXT:`, etc.) flags files referencing v1-specific infrastructure — content is NOT modified, just flagged in the handoff. - **migrate-env** — merges v1 `.env` into v2 `.env`, never overwriting existing v2 keys. - **migrate-channel-auth** — per-channel registry tracks v1 env keys, v2 required keys (with source-of-key instructions — e.g. Discord needs `DISCORD_PUBLIC_KEY` which v1 never stored), and candidate on-disk auth state paths (Baileys keystore, matrix sync state, etc.). Missing required v2 keys surface as actionable followups and flip the step to `partial`. - **migrate-channels** — runs `setup/install-<channel>.sh` for each detected channel in non-interactive mode. Install-script output is captured to `logs/setup-migration/install-<channel>.log` sidecars (silent under the parent spinner). Channels with no v2 adapter get a `not_supported` followup but don't degrade status. - **migrate-tasks** — v1 `scheduled_tasks` → `messages_in` rows with `kind='task'` in each session's `inbound.db`. `schedule_type` mapping (cron / interval / once → v2 cron). Idempotent: skips v1 task ids already present. Inactive rows dumped to `inactive-tasks.json` for reference. Everything writes to `logs/setup-migration/handoff.json` — the source of truth the skill consumes. `.claude/skills/migrate-from-v1/SKILL.md`: - **Phase A** (always): owner seeding + v1 access policy flip (`unknown_sender_policy` public/strict) via `AskUserQuestion`. Pulls sender candidates from v1's `messages` table as hints. - **Phase B** (if followups exist): walks `handoff.followups` — translates `.v1-container-config.json` sidecars, handles `not_supported` channels, fills in missing required keys with instructions on where to get them. - **Phase C** (fork-aware): `git log <upstream>..HEAD` in v1. Empty → "no customizations to port." Non-empty → scope choice (mechanical / full interview / reference-only). Portable categories (`container/skills/*`, `.claude/skills/*`, docs) scan+copy with `scanForV1Patterns`. Non-portable (`src/*`, `container/agent-runner/src/*`) stash to `docs/v1-fork-reference/` — explicit "don't translate v1 infra to v2" warning because v1's IPC file queue / single DB don't exist in v2. Clearly marked in README, CLAUDE.md, SKILL.md header, and via a `p.warn` that fires once per run when v1 is detected. Users with no v1 install see a silent skip — no prompts, no noise. Verified end-to-end against a live v1 install (300 discord + 1 discord-supervisor groups, fork with ~15 commits of PR-factory work): - Detect → validate → db (301 rows seeded) → groups (301 CLAUDE.local.md + 178 other files + 1 container_config sidecar) → env (4 keys copied) → channel-auth (flagged missing `DISCORD_APPLICATION_ID` + `DISCORD_PUBLIC_KEY`) → channels (discord installed, discord-supervisor → not_supported) → tasks (0 rows, skipped) - Idempotent re-run: 0 rows created, 903 rows reused; tasks skip if id already present - Fresh-user case: silent skip, no prompts, straight to "You're ready!" - Schema-mismatch case: recorded to `schema-mismatch.json`, chain continues - Unit tests for the pure transforms (`parseJid`, `inferChannelType`, `triggerToEngage`, `scanForV1Patterns`, `looksLikeV1Install`) - Validate `requiredV2Keys` for telegram/slack/matrix/teams/webex/ resend/linear against the actual Chat SDK packages (Discord was verified from real error output) - Widen candidate auth file paths for WhatsApp/Matrix/iMessage based on real non-Discord v1 installs once we have some See docs/v1-to-v2-changes.md for the v1 → v2 architecture diff.
This commit is contained in:
@@ -14,8 +14,10 @@
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|timezone|channel|verify|
|
||||
* first-chat)
|
||||
* service|cli-agent|timezone|migration|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
|
||||
@@ -36,6 +38,7 @@ import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { runMigrateV1 } from './migrate-v1.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
@@ -306,7 +309,16 @@ 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();
|
||||
}
|
||||
|
||||
let channelChoice: ChannelChoice = 'skip';
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice === 'telegram') {
|
||||
|
||||
@@ -22,6 +22,14 @@ 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
setup/migrate-v1.ts
Normal file
257
setup/migrate-v1.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
213
setup/migrate-v1/channel-auth.ts
Normal file
213
setup/migrate-v1/channel-auth.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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,
|
||||
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). Surface missing ones as actionable followups.
|
||||
const v2EnvPath = path.join(process.cwd(), '.env');
|
||||
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,
|
||||
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
setup/migrate-v1/channels.ts
Normal file
172
setup/migrate-v1/channels.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
296
setup/migrate-v1/db.ts
Normal file
296
setup/migrate-v1/db.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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 {
|
||||
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);
|
||||
|
||||
// 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 platformId = v2PlatformId(channelType, g.jid);
|
||||
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
setup/migrate-v1/detect.ts
Normal file
107
setup/migrate-v1/detect.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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
setup/migrate-v1/env.ts
Normal file
135
setup/migrate-v1/env.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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
setup/migrate-v1/groups.ts
Normal file
230
setup/migrate-v1/groups.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
639
setup/migrate-v1/shared.ts
Normal file
639
setup/migrate-v1/shared.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* Shared types, constants, and helpers for the v1 → v2 migration.
|
||||
*
|
||||
* The migration is a sequence of small steps registered in setup/index.ts
|
||||
* (migrate-detect, migrate-validate, migrate-db, …). Every step:
|
||||
* - Reads state it needs from `logs/setup-migration/handoff.json`
|
||||
* - Writes its own outcome back to that handoff file
|
||||
* - Emits exactly one `=== NANOCLAW SETUP: MIGRATE_<X> ===` block on stdout
|
||||
*
|
||||
* No step aborts the chain on failure — the orchestrator in setup/migrate-v1.ts
|
||||
* reads the handoff after each step to decide whether to continue, skip, or
|
||||
* hand off to the Claude `/migrate-from-v1` skill.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MIGRATION_DIR = path.join('logs', 'setup-migration');
|
||||
export const HANDOFF_PATH = path.join(MIGRATION_DIR, 'handoff.json');
|
||||
export const SCHEMA_MISMATCH_PATH = path.join(MIGRATION_DIR, 'schema-mismatch.json');
|
||||
export const INACTIVE_TASKS_PATH = path.join(MIGRATION_DIR, 'inactive-tasks.json');
|
||||
|
||||
// ── V1 install discovery ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default candidate paths to scan for a v1 install. Combines:
|
||||
* - `$NANOCLAW_V1_PATH` (explicit override, takes priority)
|
||||
* - Sibling directories of the v2 checkout whose name contains "nanoclaw"
|
||||
* or "clawdbot" (most common layout — v1 lives next to v2)
|
||||
* - Common checkout locations under $HOME
|
||||
* - Common XDG-style state dirs (.nanoclaw, .clawdbot — v1's predecessor)
|
||||
*
|
||||
* Kept generic — don't bake specific usernames in. Deduped so a path that
|
||||
* satisfies multiple rules only appears once.
|
||||
*/
|
||||
export function defaultV1Candidates(): string[] {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
const cwdParent = path.dirname(cwd);
|
||||
|
||||
const siblings: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(cwdParent)) {
|
||||
for (const entry of fs.readdirSync(cwdParent, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const lower = entry.name.toLowerCase();
|
||||
// Match anything claw-ish next to v2: "nanoclaw", "nanoclaw-v1",
|
||||
// "clawdbot", user's fork name like "nanoclaw-prod". Excludes the
|
||||
// v2 checkout we're running from so we don't self-match.
|
||||
if (!lower.includes('claw')) continue;
|
||||
const full = path.join(cwdParent, entry.name);
|
||||
if (path.resolve(full) === path.resolve(cwd)) continue;
|
||||
siblings.push(full);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't list parent — fall through to the fixed list.
|
||||
}
|
||||
|
||||
const fixed = [
|
||||
path.join(home, 'nanoclaw'),
|
||||
path.join(home, '.nanoclaw'),
|
||||
path.join(home, 'clawdbot'),
|
||||
path.join(home, '.clawdbot'),
|
||||
path.join(home, 'Code', 'nanoclaw'),
|
||||
path.join(home, 'code', 'nanoclaw'),
|
||||
path.join(home, 'projects', 'nanoclaw'),
|
||||
path.join(home, 'Projects', 'nanoclaw'),
|
||||
path.join(home, 'src', 'nanoclaw'),
|
||||
path.join(home, 'dev', 'nanoclaw'),
|
||||
path.join(home, 'workspace', 'nanoclaw'),
|
||||
path.join(home, 'Documents', 'nanoclaw'),
|
||||
path.join(home, 'GitHub', 'nanoclaw'),
|
||||
path.join(home, 'github', 'nanoclaw'),
|
||||
path.join(home, 'repos', 'nanoclaw'),
|
||||
];
|
||||
|
||||
// NANOCLAW_V1_PATH is handled authoritatively by detect.ts — if it's set,
|
||||
// detect doesn't call this function at all. So we only build the
|
||||
// auto-discovery list here.
|
||||
const all = [...siblings, ...fixed];
|
||||
|
||||
// Dedupe by resolved path. A sibling "nanoclaw" and a fixed "$HOME/nanoclaw"
|
||||
// often resolve to the same thing on single-user machines.
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const p of all) {
|
||||
const resolved = path.resolve(p);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface V1Paths {
|
||||
root: string;
|
||||
db: string;
|
||||
env: string;
|
||||
groups: string;
|
||||
packageJson: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the expected v1 file layout relative to a root. All paths are returned
|
||||
* even if they don't exist — callers check existence on the ones they care about.
|
||||
*/
|
||||
export function v1PathsFor(root: string): V1Paths {
|
||||
return {
|
||||
root,
|
||||
db: path.join(root, 'store', 'messages.db'),
|
||||
env: path.join(root, '.env'),
|
||||
groups: path.join(root, 'groups'),
|
||||
packageJson: path.join(root, 'package.json'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick "does this path look like a v1 install?" check — used by detect.
|
||||
*
|
||||
* Strategy: the strongest signal is `store/messages.db`, so that's required.
|
||||
* The package.json check is a weaker corroboration — forks may rename
|
||||
* `"name"` or strip it, so we allow:
|
||||
* - `name` missing or non-string
|
||||
* - `name` containing "nanoclaw" or "clawdbot" (case-insensitive)
|
||||
* We reject only if `name` looks like a completely unrelated project, OR
|
||||
* the version is 2.x (the v2 rewrite itself).
|
||||
*
|
||||
* This keeps stock + forked v1 installs detectable while filtering out
|
||||
* unrelated repos that happen to have a `store/messages.db`.
|
||||
*/
|
||||
export function looksLikeV1Install(root: string): { ok: boolean; reason?: string } {
|
||||
if (!fs.existsSync(root)) return { ok: false, reason: 'root_missing' };
|
||||
const { db, packageJson } = v1PathsFor(root);
|
||||
if (!fs.existsSync(db)) return { ok: false, reason: 'db_missing' };
|
||||
|
||||
// package.json is optional — a user may have stripped it, or be running
|
||||
// from a state-only dir (.nanoclaw). The DB shape is checked separately
|
||||
// by migrate-validate, which is authoritative for "is this schema v1?"
|
||||
if (!fs.existsSync(packageJson)) return { ok: true };
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf-8')) as { name?: string; version?: string };
|
||||
const name = (pkg.name ?? '').toLowerCase();
|
||||
if (pkg.version && /^2\./.test(pkg.version)) return { ok: false, reason: 'already_v2' };
|
||||
if (name && !name.includes('nanoclaw') && !name.includes('clawdbot')) {
|
||||
return { ok: false, reason: 'unrelated_project' };
|
||||
}
|
||||
} catch {
|
||||
// Broken package.json doesn't rule out v1 — DB presence is enough.
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Handoff state (single source of truth across sub-steps) ────────────
|
||||
|
||||
/**
|
||||
* Rich state shared between migration sub-steps. Each step reads the whole
|
||||
* file, merges its section, and writes it back. Never hand-edit — it's
|
||||
* consumed by the `/migrate-from-v1` skill too.
|
||||
*
|
||||
* All paths stored are ABSOLUTE, so subsequent steps don't need to guess
|
||||
* about cwd. Relative paths would be a footgun once the skill reads this
|
||||
* file later from a different cwd.
|
||||
*/
|
||||
export interface Handoff {
|
||||
version: 1;
|
||||
started_at: string;
|
||||
v1_path: string | null;
|
||||
v1_version: string | null;
|
||||
|
||||
/** Overall status once migrate-handoff finalizes the run. */
|
||||
overall_status: 'pending' | 'success' | 'partial' | 'failed' | 'skipped';
|
||||
|
||||
steps: Partial<Record<MigrateStep, StepOutcome>>;
|
||||
|
||||
/** Group folders the user chose to bring over (migrate-db populates). */
|
||||
group_selection: {
|
||||
mode: 'all' | 'wired-only' | 'cancelled' | null;
|
||||
selected_folders: string[];
|
||||
total_v1_groups: number;
|
||||
wired_v1_groups: number;
|
||||
};
|
||||
|
||||
/** Distinct channels inferred from v1 registered_groups. */
|
||||
detected_channels: Array<{
|
||||
channel_type: string;
|
||||
source: 'channel_name' | 'jid_prefix';
|
||||
group_count: number;
|
||||
}>;
|
||||
|
||||
/** Per-channel auth copy results (migrate-channel-auth populates). */
|
||||
channel_auth: Array<{
|
||||
channel_type: string;
|
||||
env_keys_copied: string[];
|
||||
files_copied: string[];
|
||||
files_missing: string[];
|
||||
notes: string;
|
||||
}>;
|
||||
|
||||
/** Result of each `setup/install-<channel>.sh` invocation. */
|
||||
channels_installed: Array<{
|
||||
channel_type: string;
|
||||
status: 'success' | 'failed' | 'skipped' | 'not_supported';
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
/** Scheduled-task migration results (migrate-tasks populates). */
|
||||
tasks: {
|
||||
v1_active: number;
|
||||
v1_inactive: number;
|
||||
migrated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/** Things the skill must finish manually. Always safe to append to. */
|
||||
followups: string[];
|
||||
}
|
||||
|
||||
export type MigrateStep =
|
||||
| 'migrate-detect'
|
||||
| 'migrate-validate'
|
||||
| 'migrate-db'
|
||||
| 'migrate-groups'
|
||||
| 'migrate-env'
|
||||
| 'migrate-channel-auth'
|
||||
| 'migrate-channels'
|
||||
| 'migrate-tasks'
|
||||
| 'migrate-handoff';
|
||||
|
||||
export interface StepOutcome {
|
||||
status: 'success' | 'partial' | 'failed' | 'skipped';
|
||||
fields: Record<string, string | number | boolean>;
|
||||
notes: string[];
|
||||
at: string;
|
||||
}
|
||||
|
||||
function emptyHandoff(): Handoff {
|
||||
return {
|
||||
version: 1,
|
||||
started_at: new Date().toISOString(),
|
||||
v1_path: null,
|
||||
v1_version: null,
|
||||
overall_status: 'pending',
|
||||
steps: {},
|
||||
group_selection: {
|
||||
mode: null,
|
||||
selected_folders: [],
|
||||
total_v1_groups: 0,
|
||||
wired_v1_groups: 0,
|
||||
},
|
||||
detected_channels: [],
|
||||
channel_auth: [],
|
||||
channels_installed: [],
|
||||
tasks: { v1_active: 0, v1_inactive: 0, migrated: 0, failed: 0, skipped: 0 },
|
||||
followups: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the handoff, creating an empty one if it doesn't exist yet. */
|
||||
export function readHandoff(): Handoff {
|
||||
fs.mkdirSync(MIGRATION_DIR, { recursive: true });
|
||||
if (!fs.existsSync(HANDOFF_PATH)) return emptyHandoff();
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(HANDOFF_PATH, 'utf-8')) as Handoff;
|
||||
if (parsed.version !== 1) throw new Error(`unsupported handoff version ${parsed.version}`);
|
||||
return parsed;
|
||||
} catch {
|
||||
// Broken handoff shouldn't wedge the migration — start fresh and let the
|
||||
// step that called us re-record its outcome.
|
||||
return emptyHandoff();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a handoff mutation atomically (write-tmp + rename). */
|
||||
export function writeHandoff(h: Handoff): void {
|
||||
fs.mkdirSync(MIGRATION_DIR, { recursive: true });
|
||||
const tmp = HANDOFF_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(h, null, 2));
|
||||
fs.renameSync(tmp, HANDOFF_PATH);
|
||||
}
|
||||
|
||||
/** Convenience: merge a step outcome into the handoff and persist. */
|
||||
export function recordStep(step: MigrateStep, outcome: StepOutcome): void {
|
||||
const h = readHandoff();
|
||||
h.steps[step] = outcome;
|
||||
writeHandoff(h);
|
||||
}
|
||||
|
||||
// ── JID parsing + channel inference ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* v1 stored chat identifiers as `<prefix>:<id>` in `registered_groups.jid`.
|
||||
* The prefix was often a short code (`dc` for Discord, `tg` for Telegram)
|
||||
* that doesn't match v2's `channel_type` names. This table normalizes them.
|
||||
*
|
||||
* Unknown prefixes fall through as-is (`channel_type = prefix`) so a channel
|
||||
* we didn't anticipate still ends up with a distinct messaging_group per
|
||||
* chat — the skill can reconcile it interactively.
|
||||
*/
|
||||
export const JID_PREFIX_TO_CHANNEL: Record<string, string> = {
|
||||
dc: 'discord',
|
||||
discord: 'discord',
|
||||
tg: 'telegram',
|
||||
telegram: 'telegram',
|
||||
wa: 'whatsapp',
|
||||
whatsapp: 'whatsapp',
|
||||
slack: 'slack',
|
||||
matrix: 'matrix',
|
||||
mx: 'matrix',
|
||||
teams: 'teams',
|
||||
imessage: 'imessage',
|
||||
im: 'imessage',
|
||||
email: 'email',
|
||||
webex: 'webex',
|
||||
gchat: 'gchat',
|
||||
linear: 'linear',
|
||||
github: 'github',
|
||||
};
|
||||
|
||||
export interface ParsedJid {
|
||||
raw: string;
|
||||
prefix: string;
|
||||
id: string;
|
||||
channel_type: string;
|
||||
}
|
||||
|
||||
export function parseJid(raw: string): ParsedJid | null {
|
||||
const colon = raw.indexOf(':');
|
||||
if (colon === -1) return null;
|
||||
const prefix = raw.slice(0, colon).toLowerCase();
|
||||
const id = raw.slice(colon + 1);
|
||||
if (!prefix || !id) return null;
|
||||
return {
|
||||
raw,
|
||||
prefix,
|
||||
id,
|
||||
channel_type: JID_PREFIX_TO_CHANNEL[prefix] ?? prefix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer an explicit v1 `channel_name` when one is set; fall back to the JID
|
||||
* prefix. v1 left `channel_name` empty on most rows (it was a late addition),
|
||||
* so the JID prefix is often the only honest source.
|
||||
*/
|
||||
export function inferChannelType(jid: string, channelName: string | null): string | null {
|
||||
if (channelName && channelName.trim()) return channelName.trim();
|
||||
const parsed = parseJid(jid);
|
||||
return parsed?.channel_type ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2's messaging_groups.platform_id is always prefixed with the channel_type
|
||||
* (see setup/register.ts:118-120). This helper normalizes v1's `jid` into
|
||||
* that shape so router lookups at runtime find the right row.
|
||||
*/
|
||||
export function v2PlatformId(channelType: string, jid: string): string {
|
||||
const parsed = parseJid(jid);
|
||||
const id = parsed?.id ?? jid;
|
||||
return id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`;
|
||||
}
|
||||
|
||||
// ── Trigger rules → engage mode (ports migration 010's backfill) ───────
|
||||
|
||||
/**
|
||||
* Mirrors the backfill() logic in src/db/migrations/010-engage-modes.ts so
|
||||
* rows written by the migration land in the same shape as rows written by
|
||||
* setup/register.ts (which goes through migration 010 at boot).
|
||||
*/
|
||||
export function triggerToEngage(input: {
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
}): {
|
||||
engage_mode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
engage_pattern: string | null;
|
||||
} {
|
||||
const pattern = input.trigger_pattern && input.trigger_pattern.trim().length > 0 ? input.trigger_pattern : null;
|
||||
const requiresTrigger = input.requires_trigger !== 0; // NULL/1 → true; 0 → false
|
||||
|
||||
if (pattern === '.' || pattern === '.*') {
|
||||
return { engage_mode: 'pattern', engage_pattern: '.' };
|
||||
}
|
||||
if (pattern) {
|
||||
return { engage_mode: 'pattern', engage_pattern: pattern };
|
||||
}
|
||||
if (!requiresTrigger) {
|
||||
return { engage_mode: 'pattern', engage_pattern: '.' };
|
||||
}
|
||||
return { engage_mode: 'mention', engage_pattern: null };
|
||||
}
|
||||
|
||||
// ── Channel auth registry (non-.env state per channel) ─────────────────
|
||||
|
||||
/**
|
||||
* Describes the auth surface for a channel beyond `.env`. Each entry tells
|
||||
* the channel-auth step:
|
||||
*
|
||||
* - `v1EnvKeys`: env keys we might find on the v1 side and carry over
|
||||
* - `requiredV2Keys`: env keys v2's adapter REQUIRES to boot — if missing
|
||||
* from v2's merged .env after migrate-env runs, a followup is emitted so
|
||||
* the user knows exactly what to add (and where to get it).
|
||||
* - `candidatePaths`: relative paths under the v1 root that may hold
|
||||
* on-disk auth state (WhatsApp keystore, matrix sync state, etc.)
|
||||
* - `note`: short human-readable hint surfaced to the user
|
||||
*
|
||||
* Unknown channels fall through as {v1EnvKeys:[], requiredV2Keys:[],
|
||||
* candidatePaths:[]} — the skill asks the user how to proceed.
|
||||
*
|
||||
* Keep `requiredV2Keys` honest: list only what the v2 adapter actually
|
||||
* refuses to boot without. False positives spam the followups; false
|
||||
* negatives let the agent silently fail. Verify against the actual
|
||||
* `@chat-adapter/<name>` package when adding/updating entries.
|
||||
*/
|
||||
export interface ChannelAuthSpec {
|
||||
v1EnvKeys: string[];
|
||||
requiredV2Keys: { key: string; where: string }[];
|
||||
candidatePaths: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const CHANNEL_AUTH_REGISTRY: Record<string, ChannelAuthSpec> = {
|
||||
discord: {
|
||||
v1EnvKeys: ['DISCORD_BOT_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'],
|
||||
// v1 used raw discord.js (bot token only). v2 uses Chat SDK which needs
|
||||
// the interaction-verification public key + application id on top.
|
||||
requiredV2Keys: [
|
||||
{ key: 'DISCORD_BOT_TOKEN', where: 'Discord Developer Portal → Application → Bot → Token' },
|
||||
{ key: 'DISCORD_APPLICATION_ID', where: 'Discord Developer Portal → Application → General → Application ID' },
|
||||
{ key: 'DISCORD_PUBLIC_KEY', where: 'Discord Developer Portal → Application → General → Public Key' },
|
||||
],
|
||||
candidatePaths: [],
|
||||
note: 'v1 used raw discord.js (bot token only). v2 uses Chat SDK and needs APPLICATION_ID + PUBLIC_KEY too.',
|
||||
},
|
||||
'discord-supervisor': {
|
||||
v1EnvKeys: ['DISCORD_SUPERVISOR_BOT_TOKEN'],
|
||||
requiredV2Keys: [],
|
||||
candidatePaths: [],
|
||||
note: 'v1-specific secondary bot. v2 does not have a native supervisor channel; the token is preserved in .env for the skill to reconcile.',
|
||||
},
|
||||
telegram: {
|
||||
v1EnvKeys: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_API_ID', 'TELEGRAM_API_HASH'],
|
||||
requiredV2Keys: [
|
||||
{ key: 'TELEGRAM_BOT_TOKEN', where: 'BotFather on Telegram → /mybots → Bot → API Token' },
|
||||
],
|
||||
candidatePaths: ['data/sessions/telegram', 'store/telegram-session'],
|
||||
},
|
||||
whatsapp: {
|
||||
v1EnvKeys: ['WHATSAPP_PHONE', 'WHATSAPP_OWNER'],
|
||||
requiredV2Keys: [],
|
||||
candidatePaths: [
|
||||
'data/sessions/baileys',
|
||||
'data/baileys_auth',
|
||||
'store/auth_info_baileys',
|
||||
'store/baileys',
|
||||
'auth_info_baileys',
|
||||
],
|
||||
note: 'Baileys keystore — copying is best-effort. Encryption sessions may still need a fresh pair via /add-whatsapp.',
|
||||
},
|
||||
matrix: {
|
||||
v1EnvKeys: ['MATRIX_HOMESERVER', 'MATRIX_USER_ID', 'MATRIX_ACCESS_TOKEN'],
|
||||
requiredV2Keys: [
|
||||
{ key: 'MATRIX_HOMESERVER', where: 'your Matrix homeserver URL (e.g. https://matrix.org)' },
|
||||
{ key: 'MATRIX_ACCESS_TOKEN', where: 'Element → Settings → Help & About → Access Token (keep secret)' },
|
||||
],
|
||||
candidatePaths: ['data/matrix-store', 'store/matrix', 'data/sessions/matrix'],
|
||||
},
|
||||
slack: {
|
||||
v1EnvKeys: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_SIGNING_SECRET'],
|
||||
requiredV2Keys: [
|
||||
{ key: 'SLACK_BOT_TOKEN', where: 'Slack app → OAuth & Permissions → Bot User OAuth Token (xoxb-…)' },
|
||||
{ key: 'SLACK_SIGNING_SECRET', where: 'Slack app → Basic Information → Signing Secret' },
|
||||
],
|
||||
candidatePaths: [],
|
||||
},
|
||||
teams: {
|
||||
v1EnvKeys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_TENANT_ID'],
|
||||
requiredV2Keys: [
|
||||
{ key: 'TEAMS_APP_ID', where: 'Azure portal → App registration → Application (client) ID' },
|
||||
{ key: 'TEAMS_APP_PASSWORD', where: 'Azure portal → App registration → Certificates & secrets' },
|
||||
],
|
||||
candidatePaths: [],
|
||||
},
|
||||
imessage: {
|
||||
v1EnvKeys: ['IMESSAGE_PHOTON_URL', 'IMESSAGE_PHOTON_TOKEN'],
|
||||
requiredV2Keys: [],
|
||||
candidatePaths: ['data/imessage', 'store/imessage'],
|
||||
},
|
||||
webex: {
|
||||
v1EnvKeys: ['WEBEX_BOT_TOKEN'],
|
||||
requiredV2Keys: [{ key: 'WEBEX_BOT_TOKEN', where: 'Webex developer portal → Bot → Bot Access Token' }],
|
||||
candidatePaths: [],
|
||||
},
|
||||
gchat: {
|
||||
v1EnvKeys: ['GCHAT_SERVICE_ACCOUNT', 'GCHAT_WEBHOOK_URL'],
|
||||
requiredV2Keys: [],
|
||||
candidatePaths: ['data/gchat-credentials.json', 'store/gchat-sa.json'],
|
||||
},
|
||||
resend: {
|
||||
v1EnvKeys: ['RESEND_API_KEY', 'RESEND_FROM'],
|
||||
requiredV2Keys: [{ key: 'RESEND_API_KEY', where: 'resend.com → API Keys' }],
|
||||
candidatePaths: [],
|
||||
},
|
||||
github: {
|
||||
v1EnvKeys: ['GITHUB_WEBHOOK_SECRET', 'GITHUB_APP_ID', 'GITHUB_PRIVATE_KEY_PATH'],
|
||||
requiredV2Keys: [],
|
||||
candidatePaths: [],
|
||||
note: 'Webhook channel — secrets carry over, but GitHub webhook URLs are new per v2 install.',
|
||||
},
|
||||
linear: {
|
||||
v1EnvKeys: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'],
|
||||
requiredV2Keys: [{ key: 'LINEAR_API_KEY', where: 'Linear → Settings → API → Personal API keys' }],
|
||||
candidatePaths: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a v2 `channel_type` name to the corresponding `setup/install-<x>.sh`
|
||||
* script, if one exists. `null` means no v2 skill is available yet — the
|
||||
* handoff lists the channel as "not supported" and the skill raises it with
|
||||
* the user.
|
||||
*/
|
||||
export function installScriptForChannel(channelType: string): string | null {
|
||||
const known = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'whatsapp-cloud',
|
||||
'teams',
|
||||
'slack',
|
||||
'matrix',
|
||||
'imessage',
|
||||
'webex',
|
||||
'gchat',
|
||||
'resend',
|
||||
'github',
|
||||
'linear',
|
||||
]);
|
||||
if (!known.has(channelType)) return null;
|
||||
return `setup/install-${channelType}.sh`;
|
||||
}
|
||||
|
||||
// ── Misc helpers ───────────────────────────────────────────────────────
|
||||
|
||||
export function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// ── v1-specific pattern scan (for migrate-groups) ──────────────────────
|
||||
|
||||
/**
|
||||
* Tight set of v1-only infrastructure patterns. When one of these shows up
|
||||
* in a copied CLAUDE.md, the content referencing v1 plumbing that is genuinely
|
||||
* gone in v2 (IPC file queue, single-DB paths, v1 pr-factory conventions).
|
||||
*
|
||||
* Deliberately excludes portable patterns — `mcp__nanoclaw__*` tool names,
|
||||
* `agent-browser`, generic `/workspace/` paths — which v2 supports the same
|
||||
* way. The list is scan-only; the migration does NOT modify file content. It
|
||||
* just adds a followup so the /migrate-from-v1 skill can triage each file
|
||||
* with the user.
|
||||
*
|
||||
* Keep this list conservative: false positives spam the skill with noise,
|
||||
* false negatives leave the user with silently-broken agents. When adding,
|
||||
* include a comment naming the specific v1 thing each pattern points at.
|
||||
*/
|
||||
export interface V1PatternMatch {
|
||||
pattern: string;
|
||||
description: string;
|
||||
lines: number[];
|
||||
}
|
||||
|
||||
const V1_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
||||
{
|
||||
pattern: /\/workspace\/ipc\/tasks/,
|
||||
description: "v1 IPC file queue (gone in v2 — agents talk to the host via session DBs, not JSON files)",
|
||||
},
|
||||
{
|
||||
pattern: /\/workspace\/extra\/project\/store\b/,
|
||||
description: "v1-specific mount + store/ path (v2 mounts differ; state lives under data/)",
|
||||
},
|
||||
{
|
||||
pattern: /\bstore\/messages\.db\b/,
|
||||
description: "v1 central DB path (v2 uses data/v2.db + data/v2-sessions/<id>/{inbound,outbound}.db)",
|
||||
},
|
||||
{
|
||||
pattern: /"clear_session"|"retrigger"/,
|
||||
description: "v1 IPC task types (no v2 equivalent; use session lifecycle + the scheduling MCP tool instead)",
|
||||
},
|
||||
{
|
||||
pattern: /\[PR_CONTEXT:/,
|
||||
description: "v1 pr-factory context-tag convention (specific to the supervisor group; needs reworking in v2)",
|
||||
},
|
||||
{
|
||||
pattern: /\brequires_trigger\b|\btrigger_pattern\b/,
|
||||
description: "v1 column names on registered_groups (v2 uses engage_mode + engage_pattern on messaging_group_agents)",
|
||||
},
|
||||
{
|
||||
pattern: /\bchatJid\b(?!\s*[:=]\s*["']dc:)/,
|
||||
description: "v1 routing key (v2 uses messaging_group_id or channel_type+platform_id)",
|
||||
},
|
||||
];
|
||||
|
||||
/** Scan a CLAUDE.md-ish text blob for v1-specific infrastructure patterns. */
|
||||
export function scanForV1Patterns(text: string): V1PatternMatch[] {
|
||||
const matches: V1PatternMatch[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const entry of V1_PATTERNS) {
|
||||
const hitLines: number[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (entry.pattern.test(lines[i])) {
|
||||
hitLines.push(i + 1);
|
||||
}
|
||||
}
|
||||
if (hitLines.length > 0) {
|
||||
matches.push({
|
||||
pattern: entry.pattern.source,
|
||||
description: entry.description,
|
||||
// Cap to first 5 line numbers — we're generating a followup summary,
|
||||
// not a code index. Full context is in the file itself.
|
||||
lines: hitLines.slice(0, 5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '{"error":"unserializable"}';
|
||||
}
|
||||
}
|
||||
307
setup/migrate-v1/tasks.ts
Normal file
307
setup/migrate-v1/tasks.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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
setup/migrate-v1/validate.ts
Normal file
213
setup/migrate-v1/validate.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user