`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.
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
/**
|
|
* 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),
|
|
});
|
|
}
|