chore: move shared helpers to migrate-v2/, delete migrate-v1/

Extracted the helpers we use (JID parsing, trigger mapping, channel
auth registry, generateId, v2PlatformId) into setup/migrate-v2/shared.ts.
Deleted setup/migrate-v1/ entirely — no code references it anymore.

Updated README, CLAUDE.md, docs/v1-to-v2-changes.md, and
docs/migration-dev.md to reference the new paths and migrate-v2.sh
entry point.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-01 20:23:34 +00:00
parent 67eb85d818
commit f35be24aef
10 changed files with 210 additions and 752 deletions
+2 -2
View File
@@ -93,7 +93,7 @@ FROM messages
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULL
```
The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v1/shared.ts`) and combining: `<channel_type>:<sender>`.
The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `<channel_type>:<sender>`.
For each sender:
1. Upsert into `users(id, kind, display_name)` if not already present.
@@ -171,7 +171,7 @@ If there are commits:
1. Show the commit list to the user.
2. `AskUserQuestion`: "How do you want to handle your v1 customizations?"
- **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v1/shared.ts`.
- **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`.
- **Full walkthrough** — go commit by commit, decide together.
- **Reference only** — stash to `docs/v1-fork-reference/` for later.
3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate.
+3 -2
View File
@@ -77,7 +77,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
| `container/skills/` | Container skills mounted into every agent session |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `setup/migrate-v1.ts` + `setup/migrate-v1/` | v1→v2 migration (**experimental**); runs inside `setup/auto.ts` before channel pairing. Seeds `agent_groups` + `messaging_groups` + wirings from v1's `registered_groups`, copies group folders, merges `.env`, installs channel adapters, ports scheduled tasks. Writes `logs/setup-migration/handoff.json` for the `/migrate-from-v1` skill to pick up (owner seeding + fork customizations). |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
## Channels and Providers (skill-installed)
@@ -233,7 +233,8 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow |
| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture |
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved. Referenced by the migration step and `/migrate-from-v1` skill |
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
## Container Build Cache
+1 -1
View File
@@ -33,7 +33,7 @@ bash nanoclaw.sh
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
**Coming from v1?** `nanoclaw.sh` detects your old install (scans siblings of the v2 checkout + common `$HOME` locations) and migrates automatically; `/migrate-from-v1` in Claude finishes owner setup and helps port custom fork work. If v1 is at a non-standard path, run with `NANOCLAW_V1_PATH=/path/to/nanoclaw bash nanoclaw.sh`. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). _Experimental — back up `data/v2.db` and `groups/` first; not recommended yet for high-stakes production installs._
**Coming from v1?** Run `bash migrate-v2.sh` instead of `nanoclaw.sh`. It finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), migrates state, installs channels, and hands off to Claude for owner setup and custom code porting. See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) and [docs/migration-dev.md](docs/migration-dev.md).
## Philosophy
+1 -1
View File
@@ -31,7 +31,7 @@ setup/migrate-v2/
channel-auth.ts # Phase 2b: copy channel auth state
select-channels.ts # Phase 2a: clack multiselect
switchover-prompt.ts # Service switch prompts
setup/migrate-v1/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.)
setup/migrate-v2/shared.ts # Shared helpers (JID parsing, trigger mapping, etc.)
.claude/skills/migrate-from-v1/ # The Claude skill
logs/setup-migration/handoff.json # Written by migrate-v2.sh, read by skill
logs/migrate-steps/*.log # Per-step raw output
+11 -11
View File
@@ -1,6 +1,6 @@
# NanoClaw v1 → v2 — what changed
Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash nanoclaw.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here.
Big-picture differences between NanoClaw v1 (the `~/nanoclaw` checkout you've been running) and v2 (this rewrite). Not a migration guide — that's what `bash migrate-v2.sh` and the `/migrate-from-v1` skill are for. This doc is the **vocabulary**: when something has moved or been renamed, find it here.
Read this before touching the migration code or porting customizations forward.
@@ -37,7 +37,7 @@ Consequences:
- v1 `requires_trigger=0` or pattern was `.`/`.*` → v2 `engage_mode='pattern'`, `engage_pattern='.'` (the "always" flavor)
- no pattern and requires a trigger → v2 `engage_mode='mention'`
- `sender_scope` and `ignored_message_policy` are new; defaults `all` / `drop`
- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc``discord`, `tg``telegram`, `wa``whatsapp`) are in `setup/migrate-v1/shared.ts`.
- **JID decomposition.** v1's `jid` column stored `dc:12345` / `tg:67890`. v2 splits this into `channel_type` + `platform_id`. Concretely: `dc:12345` becomes `channel_type='discord'`, `platform_id='discord:12345'`. Prefix aliases (`dc``discord`, `tg``telegram`, `wa``whatsapp`) are in `setup/migrate-v2/shared.ts`.
- **`channel_name` was unreliable in v1.** Many rows had it empty; the actual channel had to be guessed from the JID prefix. v2's `channel_type` is always explicit.
---
@@ -99,7 +99,7 @@ Gotcha: auto-created agents default to `selective` secret mode — no secrets at
Idempotent — re-running is a no-op. Pinned versions keep the supply chain honest. The automated migration detects which channels were wired in v1 (via distinct `channel_name` / JID prefix) and runs the matching `setup/install-<channel>.sh` for each. Channels in v1 that don't have a v2 skill (rare now, more common as v2 catches up) are recorded in the handoff file for the `/migrate-from-v1` skill to raise with the user.
**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` sub-step has a per-channel registry (`setup/migrate-v1/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys.
**Channel auth beyond `.env`.** Some channels store session state on disk (Baileys WhatsApp keystore, Matrix sync state, iMessage tokens). The `channel-auth` step has a per-channel registry (`setup/migrate-v2/shared.ts: CHANNEL_AUTH_REGISTRY`) that knows which file globs to copy alongside env keys.
---
@@ -162,11 +162,11 @@ Lockfiles: host uses `pnpm-lock.yaml`, agent-runner uses `bun.lock`. `minimumRel
## Migration surface — where the code lives
- `setup/migrate-v1.ts`orchestrator called from `setup/auto.ts` between the timezone and channel steps.
- `setup/migrate-v1/<sub-step>.ts` — registered in `setup/index.ts` STEPS; each runs under `runQuietStep`.
- `logs/setup.log` — progression log. Each sub-step appends one entry.
- `logs/setup-steps/NN-migrate-<x>.log` — raw per-sub-step stdout/stderr.
- `logs/setup-migration/handoff.json` — summary of what was migrated, what failed, what was deferred. Read by the `/migrate-from-v1` skill.
- `logs/setup-migration/schema-mismatch.json` — written only if `migrate-validate` finds a v1 DB that doesn't match the expected shape. The skill uses this to decide what to hand back to the user.
- `logs/setup-migration/inactive-tasks.json` — completed or stopped v1 `scheduled_tasks`, exported for reference.
- `.claude/skills/migrate-from-v1/SKILL.md` — tells Claude how to finish anything the automation couldn't and then interview the user about custom code changes.
- `migrate-v2.sh`entry point: `bash migrate-v2.sh` from the v2 checkout.
- `setup/migrate-v2/*.ts` — individual migration steps (env, db, groups, sessions, tasks, channel-auth, select-channels, switchover-prompt).
- `setup/migrate-v2/shared.ts` — JID parsing, trigger mapping, channel auth registry.
- `logs/setup-migration/handoff.json` — written by `migrate-v2.sh`, read by the `/migrate-from-v1` skill.
- `logs/migrate-steps/*.log` — raw per-step stdout.
- `.claude/skills/migrate-from-v1/SKILL.md` — Claude skill for owner seeding, CLAUDE.md cleanup, container config validation, fork porting.
- `migrate-v2-reset.sh` — development helper to wipe v2 state for re-testing.
- See [docs/migration-dev.md](migration-dev.md) for the full development guide.
-731
View File
@@ -1,731 +0,0 @@
/**
* 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.
*
* Some channels need extra structure on the id itself. Discord's Chat SDK
* emits `discord:<guild_id>:<channel_id>` at runtime but v1 only stored
* `dc:<channel_id>` (no guild). Callers that know the guild (e.g. bot with
* a single guild) can pass it via `extra`; otherwise the returned id will
* be the v1-format `discord:<channel_id>` and will be repaired on first
* message via v2's channel-registration approval flow.
*/
export function v2PlatformId(channelType: string, jid: string, extra?: { guildId?: string }): string {
const parsed = parseJid(jid);
const id = parsed?.id ?? jid;
const prefixed = id.startsWith(`${channelType}:`) ? id : `${channelType}:${id}`;
// For Discord: splice the guild id in between when we know it and the id
// isn't already in `<guild>:<channel>` form.
if (channelType === 'discord' && extra?.guildId) {
const body = prefixed.slice(`discord:`.length);
if (!body.includes(':')) return `discord:${extra.guildId}:${body}`;
}
return prefixed;
}
/**
* Fetch the bot's guild memberships for a channel_type so migrate-db can
* form platform_ids matching what the v2 adapter emits at runtime. Returns
* null on any failure (network, auth, rate limit, unsupported channel_type)
* — callers fall back to the v1-format platform_id, which works but may
* trigger v2's channel-registration flow on first message.
*
* Currently handles Discord. Extending to other channels: the function
* needs a "single-or-multi guild?" shape; for single-guild bots the caller
* can splice the guild id globally, for multi-guild a per-channel lookup
* is needed and the caller should probably bail (rate-limit risk).
*/
export async function fetchBotGuilds(
channelType: string,
v1EnvLookup: (key: string) => string | undefined,
): Promise<{ guildIds: string[] } | null> {
if (channelType !== 'discord') return null;
const token = v1EnvLookup('DISCORD_BOT_TOKEN');
if (!token) return null;
try {
const resp = await fetch('https://discord.com/api/v10/users/@me/guilds', {
headers: { Authorization: `Bot ${token}` },
});
if (!resp.ok) return null;
const data = (await resp.json()) as Array<{ id?: string }>;
const guildIds = data.map((g) => g.id).filter((id): id is string => typeof id === 'string');
return { guildIds };
} catch {
return null;
}
}
// ── 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: '.' };
}
// requires_trigger=0 means "respond to everything" regardless of pattern.
// The pattern was used for mention highlighting, not message gating.
if (!requiresTrigger) {
return { engage_mode: 'pattern', engage_pattern: '.' };
}
if (pattern) {
return { engage_mode: 'pattern', engage_pattern: 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: [],
},
};
/**
* For channels where v2's adapter needs keys v1 never stored (e.g. Discord's
* Chat SDK wants DISCORD_APPLICATION_ID + DISCORD_PUBLIC_KEY, but v1 used
* raw discord.js with just the bot token), try to derive the missing keys
* from the v1 creds we already have by calling the channel's API.
*
* Returns a map of key → value for what we successfully resolved.
* Never throws; returns `{}` on any failure (network, auth, unexpected
* shape). The caller writes the resolved keys to v2 .env, then re-checks
* `requiredV2Keys` so the step reports `success` instead of `partial` when
* auto-resolution covered the gap.
*
* Adding a new channel resolver: pull the needed values from an endpoint
* that accepts only the v1-side credential (bot token, API key). Don't
* prompt, don't log values. If the endpoint has rate limits, keep this
* best-effort and fail silently.
*/
export async function autoResolveV2Keys(
channelType: string,
v1EnvLookup: (key: string) => string | undefined,
): Promise<Record<string, string>> {
if (channelType === 'discord') {
const token = v1EnvLookup('DISCORD_BOT_TOKEN');
if (!token) return {};
try {
const resp = await fetch('https://discord.com/api/v10/oauth2/applications/@me', {
headers: { Authorization: `Bot ${token}` },
});
if (!resp.ok) return {};
const data = (await resp.json()) as { id?: string; verify_key?: string };
const out: Record<string, string> = {};
if (typeof data.id === 'string' && data.id) out.DISCORD_APPLICATION_ID = data.id;
if (typeof data.verify_key === 'string' && data.verify_key) {
out.DISCORD_PUBLIC_KEY = data.verify_key;
}
return out;
} catch {
return {};
}
}
return {};
}
/**
* 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"}';
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
import fs from 'fs';
import path from 'path';
import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js';
import { CHANNEL_AUTH_REGISTRY } from './shared.js';
function parseEnv(filePath: string): Map<string, string> {
const out = new Map<string, string>();
+1 -2
View File
@@ -29,8 +29,7 @@ import {
generateId,
parseJid,
triggerToEngage,
JID_PREFIX_TO_CHANNEL,
} from '../migrate-v1/shared.js';
} from './shared.js';
interface V1Group {
jid: string;
+189
View File
@@ -0,0 +1,189 @@
/**
* Shared helpers for the v1 → v2 migration steps.
*/
// ── JID parsing ─────────────────────────────────────────────────────────
/** v1 JID prefix → v2 channel_type. Unknown prefixes pass through as-is. */
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,
};
}
/**
* Build a v2 platform_id from a v1 JID. v2's messaging_groups.platform_id
* is always `<channel_type>:<id>`.
*/
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 mapping ─────────────────────────────────────────────────────
/**
* Map v1's trigger_pattern + requires_trigger to v2's engage_mode + engage_pattern.
*
* Key rule: requires_trigger=0 means "respond to everything" regardless
* of the pattern value. The pattern was for mention highlighting, not gating.
*/
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;
if (pattern === '.' || pattern === '.*') {
return { engage_mode: 'pattern', engage_pattern: '.' };
}
if (!requiresTrigger) {
return { engage_mode: 'pattern', engage_pattern: '.' };
}
if (pattern) {
return { engage_mode: 'pattern', engage_pattern: pattern };
}
return { engage_mode: 'mention', engage_pattern: null };
}
// ── ID generation ───────────────────────────────────────────────────────
export function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// ── Channel auth registry ───────────────────────────────────────────────
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'],
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.',
},
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: [],
},
};
+1 -1
View File
@@ -22,7 +22,7 @@ import { getMessagingGroupByPlatform } from '../../src/db/messaging-groups.js';
import { runMigrations } from '../../src/db/migrations/index.js';
import { insertTask } from '../../src/modules/scheduling/db.js';
import { openInboundDb, resolveSession } from '../../src/session-manager.js';
import { parseJid, v2PlatformId } from '../migrate-v1/shared.js';
import { parseJid, v2PlatformId } from './shared.js';
interface V1Task {
id: string;