`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.
136 lines
4.0 KiB
TypeScript
136 lines
4.0 KiB
TypeScript
/**
|
|
* 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',
|
|
});
|
|
}
|