Files
nanoclaw/setup/migrate-v1/channel-auth.ts
gabi-simons 3ee7d2147e 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.
2026-04-23 13:06:14 +00:00

214 lines
8.0 KiB
TypeScript

/**
* 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)),
});
}