feat: add migrate-v2.sh — standalone v1 → v2 migration script
New entry point: `bash migrate-v2.sh` from the v2 checkout. Replaces the old setup-embedded migration flow with a standalone 4-phase script + rewritten Claude skill for the interactive parts. Phase 0: Bootstrap (Node/pnpm/deps via setup.sh) + find v1 Phase 1: Core state (env, DB, groups, sessions, tasks) Phase 2: Channels (clack multiselect, auth copy, code install) Phase 3: Infrastructure (OneCLI, auth, Docker, skills, container build) Service switchover: stop v1 → start v2 → test → keep or revert Phase 4: Handoff → exec claude "/migrate-from-v1" The skill handles: owner seeding, access policy, CLAUDE.local.md cleanup, container config validation, fork customization porting. Key fixes found during testing: - triggerToEngage: requires_trigger=0 must override non-empty pattern - unknown_sender_policy defaults to 'public' (strict drops all msgs before owner is seeded) - Service revert must stop v2 (parse unit name from step log, not early tsx one-liner that can fail) - Session continuity: copy JSONL from -workspace-group/ to -workspace-agent/ and write continuation:claude into outbound.db - container_config.additionalMounts written directly to container.json (same shape in v1 and v2) - EXIT trap writes handoff.json; explicit write_handoff before exec Includes migrate-v2-reset.sh for dev iteration and docs/migration-dev.md for testing/debugging reference. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -431,12 +431,14 @@ export function triggerToEngage(input: {
|
||||
if (pattern === '.' || pattern === '.*') {
|
||||
return { engage_mode: 'pattern', engage_pattern: '.' };
|
||||
}
|
||||
if (pattern) {
|
||||
return { engage_mode: 'pattern', engage_pattern: 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 };
|
||||
}
|
||||
|
||||
|
||||
134
setup/migrate-v2/channel-auth.ts
Normal file
134
setup/migrate-v2/channel-auth.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* migrate-v2 step: channel-auth
|
||||
*
|
||||
* Copy channel auth state from v1 to v2 for selected channels.
|
||||
* Handles both env keys and on-disk auth files (Baileys, Matrix, etc.)
|
||||
* per the CHANNEL_AUTH_REGISTRY.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/channel-auth.ts <v1-path> <channel1> [channel2...]
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { CHANNEL_AUTH_REGISTRY } from '../migrate-v1/shared.js';
|
||||
|
||||
function parseEnv(filePath: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
if (!fs.existsSync(filePath)) return out;
|
||||
for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
out.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function appendEnvKey(envPath: string, key: string, value: string): boolean {
|
||||
const existing = parseEnv(envPath);
|
||||
if (existing.has(key)) return false;
|
||||
|
||||
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
|
||||
if (content && !content.endsWith('\n')) content += '\n';
|
||||
content += `${key}=${value}\n`;
|
||||
fs.writeFileSync(envPath, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyGlob(v1Root: string, v2Root: string, relativePath: string): string[] {
|
||||
const src = path.join(v1Root, relativePath);
|
||||
if (!fs.existsSync(src)) return [];
|
||||
|
||||
const copied: string[] = [];
|
||||
const stat = fs.statSync(src);
|
||||
|
||||
if (stat.isFile()) {
|
||||
const dst = path.join(v2Root, relativePath);
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst);
|
||||
copied.push(relativePath);
|
||||
}
|
||||
} else if (stat.isDirectory()) {
|
||||
const dst = path.join(v2Root, relativePath);
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const sub = path.join(relativePath, entry.name);
|
||||
copied.push(...copyGlob(v1Root, v2Root, sub));
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
const v1Path = args[0];
|
||||
const channels = args.slice(1);
|
||||
|
||||
if (!v1Path || channels.length === 0) {
|
||||
console.error('Usage: tsx setup/migrate-v2/channel-auth.ts <v1-path> <channel1> [channel2...]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1EnvPath = path.join(v1Path, '.env');
|
||||
const v2EnvPath = path.join(process.cwd(), '.env');
|
||||
const v1Env = parseEnv(v1EnvPath);
|
||||
|
||||
let envKeysCopied = 0;
|
||||
let filesCopied = 0;
|
||||
let channelsProcessed = 0;
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const spec = CHANNEL_AUTH_REGISTRY[channel];
|
||||
if (!spec) {
|
||||
// Unknown channel — just try copying env keys with common naming
|
||||
channelsProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Copy env keys
|
||||
for (const key of spec.v1EnvKeys) {
|
||||
const value = v1Env.get(key);
|
||||
if (value) {
|
||||
if (appendEnvKey(v2EnvPath, key, value)) {
|
||||
envKeysCopied++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check required v2 keys — report missing ones
|
||||
const v2Env = parseEnv(v2EnvPath);
|
||||
for (const req of spec.requiredV2Keys) {
|
||||
if (!v2Env.has(req.key)) {
|
||||
missing.push(`${channel}:${req.key} (${req.where})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy on-disk auth files
|
||||
for (const candidate of spec.candidatePaths) {
|
||||
const copied = copyGlob(v1Path, process.cwd(), candidate);
|
||||
filesCopied += copied.length;
|
||||
}
|
||||
|
||||
channelsProcessed++;
|
||||
}
|
||||
|
||||
// Sync to data/env/env
|
||||
if (fs.existsSync(v2EnvPath)) {
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
|
||||
if (missing.length > 0) {
|
||||
console.log(`MISSING:${missing.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
162
setup/migrate-v2/db.ts
Normal file
162
setup/migrate-v2/db.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* migrate-v2 step: db
|
||||
*
|
||||
* Seed v2.db from v1's registered_groups table.
|
||||
* Creates agent_groups, messaging_groups, and messaging_group_agents.
|
||||
*
|
||||
* Does NOT seed users/user_roles — the /migrate-from-v1 skill handles that.
|
||||
*
|
||||
* Idempotent: re-running skips rows that already exist.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/db.ts <v1-path>
|
||||
*/
|
||||
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 {
|
||||
generateId,
|
||||
parseJid,
|
||||
triggerToEngage,
|
||||
JID_PREFIX_TO_CHANNEL,
|
||||
} from '../migrate-v1/shared.js';
|
||||
|
||||
interface V1Group {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const v1Path = process.argv[2];
|
||||
if (!v1Path) {
|
||||
console.error('Usage: tsx setup/migrate-v2/db.ts <v1-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1DbPath = path.join(v1Path, 'store', 'messages.db');
|
||||
if (!fs.existsSync(v1DbPath)) {
|
||||
console.error(`v1 DB not found: ${v1DbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read v1 groups
|
||||
const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
// v1 schema varies — channel_name was a late addition. Query only the
|
||||
// columns we know exist in all v1 installs.
|
||||
const v1Groups = v1Db
|
||||
.prepare('SELECT jid, name, folder, trigger_pattern, requires_trigger, is_main FROM registered_groups')
|
||||
.all() as V1Group[];
|
||||
v1Db.close();
|
||||
|
||||
if (v1Groups.length === 0) {
|
||||
console.log('SKIPPED:no registered groups in v1');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Init v2 DB
|
||||
fs.mkdirSync(path.join(process.cwd(), 'data'), { recursive: true });
|
||||
const v2Db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(v2Db);
|
||||
|
||||
let created = 0;
|
||||
let reused = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const g of v1Groups) {
|
||||
const parsed = parseJid(g.jid);
|
||||
if (!parsed) {
|
||||
skipped++;
|
||||
errors.push(`Could not parse JID: ${g.jid}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelType = parsed.channel_type;
|
||||
const platformId = parsed.raw.startsWith(`${channelType}:`)
|
||||
? parsed.raw
|
||||
: `${channelType}:${parsed.id}`;
|
||||
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)!;
|
||||
}
|
||||
|
||||
// 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,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: createdAt,
|
||||
});
|
||||
mg = getMessagingGroupByPlatform(channelType, platformId)!;
|
||||
}
|
||||
|
||||
// messaging_group_agents — wire them
|
||||
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (!existing) {
|
||||
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,
|
||||
});
|
||||
created++;
|
||||
} else {
|
||||
reused++;
|
||||
}
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
errors.push(`${g.folder}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
v2Db.close();
|
||||
|
||||
console.log(`OK:groups=${v1Groups.length},created=${created},reused=${reused},skipped=${skipped}`);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) console.log(`ERROR:${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
81
setup/migrate-v2/env.ts
Normal file
81
setup/migrate-v2/env.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* migrate-v2 step: env
|
||||
*
|
||||
* Copy every key from v1 .env into v2 .env. Never overwrites existing v2
|
||||
* keys. Idempotent — re-running skips keys already present.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/env.ts <v1-path>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function parseEnv(text: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line || 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;
|
||||
out.set(key, line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const v1Path = process.argv[2];
|
||||
if (!v1Path) {
|
||||
console.error('Usage: tsx setup/migrate-v2/env.ts <v1-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1EnvPath = path.join(v1Path, '.env');
|
||||
if (!fs.existsSync(v1EnvPath)) {
|
||||
console.log('SKIPPED:no v1 .env');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const v2EnvPath = path.join(process.cwd(), '.env');
|
||||
const v1Lines = parseEnv(fs.readFileSync(v1EnvPath, 'utf-8'));
|
||||
const v2Text = fs.existsSync(v2EnvPath) ? fs.readFileSync(v2EnvPath, 'utf-8') : '';
|
||||
const v2Lines = parseEnv(v2Text);
|
||||
|
||||
const copied: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const appended: string[] = [];
|
||||
|
||||
const BLOCK_START = '# ── migrated from v1 ──';
|
||||
const alreadyMigrated = v2Text.includes(BLOCK_START);
|
||||
|
||||
for (const [key, raw] of v1Lines) {
|
||||
if (v2Lines.has(key)) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
copied.push(key);
|
||||
appended.push(raw);
|
||||
}
|
||||
|
||||
if (appended.length > 0) {
|
||||
let result = v2Text;
|
||||
if (result && !result.endsWith('\n')) result += '\n';
|
||||
if (!alreadyMigrated) result += `\n${BLOCK_START}\n`;
|
||||
result += appended.join('\n') + '\n';
|
||||
fs.writeFileSync(v2EnvPath, result);
|
||||
}
|
||||
|
||||
// Sync to data/env/env (container reads from here)
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
console.log(`OK:copied=${copied.length},skipped=${skipped.length}`);
|
||||
if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`);
|
||||
}
|
||||
|
||||
main();
|
||||
120
setup/migrate-v2/groups.ts
Normal file
120
setup/migrate-v2/groups.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* migrate-v2 step: groups
|
||||
*
|
||||
* Copy v1 group folders into v2.
|
||||
* - v1 CLAUDE.md → v2 CLAUDE.local.md (v2 composes CLAUDE.md at spawn)
|
||||
* - v1 container_config → .v1-container-config.json sidecar
|
||||
* - All other files copied (no overwrite)
|
||||
* - Also copies global/ if it exists
|
||||
*
|
||||
* Idempotent — does not overwrite files that already exist in v2.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/groups.ts <v1-path>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SKIP_NAMES = new Set(['CLAUDE.md', 'logs', '.git', '.DS_Store', 'node_modules']);
|
||||
|
||||
/** Copy a directory tree, skipping SKIP_NAMES. Never overwrites existing files. */
|
||||
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;
|
||||
}
|
||||
if (fs.existsSync(d)) continue;
|
||||
fs.copyFileSync(s, d);
|
||||
written += 1;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const v1Path = process.argv[2];
|
||||
if (!v1Path) {
|
||||
console.error('Usage: tsx setup/migrate-v2/groups.ts <v1-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1GroupsDir = path.join(v1Path, 'groups');
|
||||
const v2GroupsDir = path.join(process.cwd(), 'groups');
|
||||
|
||||
if (!fs.existsSync(v1GroupsDir)) {
|
||||
console.log('SKIPPED:no v1 groups/ directory');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get all folders from v1 DB to know which groups are registered
|
||||
const v1DbPath = path.join(v1Path, 'store', 'messages.db');
|
||||
const registeredFolders = new Set<string>();
|
||||
if (fs.existsSync(v1DbPath)) {
|
||||
const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true });
|
||||
const rows = v1Db
|
||||
.prepare('SELECT folder, container_config FROM registered_groups')
|
||||
.all() as Array<{ folder: string; container_config: string | null }>;
|
||||
const containerConfigs = new Map<string, string | null>();
|
||||
for (const r of rows) {
|
||||
registeredFolders.add(r.folder);
|
||||
containerConfigs.set(r.folder, r.container_config);
|
||||
}
|
||||
v1Db.close();
|
||||
|
||||
// Write container.json from v1 container_config.
|
||||
// The additionalMounts shape is identical between v1 and v2.
|
||||
for (const [folder, config] of containerConfigs) {
|
||||
if (!config) continue;
|
||||
const v2Folder = path.join(v2GroupsDir, folder);
|
||||
const containerJson = path.join(v2Folder, 'container.json');
|
||||
if (fs.existsSync(containerJson)) continue;
|
||||
fs.mkdirSync(v2Folder, { recursive: true });
|
||||
try {
|
||||
const parsed = JSON.parse(config) as Record<string, unknown>;
|
||||
fs.writeFileSync(containerJson, JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
// Unparseable config — write as sidecar for the skill to handle
|
||||
fs.writeFileSync(path.join(v2Folder, '.v1-container-config.json'), config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy all v1 group folders (registered + global + any extras)
|
||||
let foldersCopied = 0;
|
||||
let claudesMigrated = 0;
|
||||
let filesCopied = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(v1GroupsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const folder = entry.name;
|
||||
const v1Folder = path.join(v1GroupsDir, folder);
|
||||
const v2Folder = path.join(v2GroupsDir, folder);
|
||||
|
||||
fs.mkdirSync(v2Folder, { recursive: true });
|
||||
|
||||
// CLAUDE.md → CLAUDE.local.md
|
||||
const v1Claude = path.join(v1Folder, 'CLAUDE.md');
|
||||
const v2Local = path.join(v2Folder, 'CLAUDE.local.md');
|
||||
if (fs.existsSync(v1Claude) && !fs.existsSync(v2Local)) {
|
||||
fs.copyFileSync(v1Claude, v2Local);
|
||||
claudesMigrated++;
|
||||
}
|
||||
|
||||
// Copy everything else
|
||||
filesCopied += copyTree(v1Folder, v2Folder);
|
||||
foldersCopied++;
|
||||
}
|
||||
|
||||
console.log(`OK:folders=${foldersCopied},claudes=${claudesMigrated},files=${filesCopied}`);
|
||||
}
|
||||
|
||||
main();
|
||||
63
setup/migrate-v2/select-channels.ts
Normal file
63
setup/migrate-v2/select-channels.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* migrate-v2: interactive channel selection via clack multiselect.
|
||||
*
|
||||
* Writes selected channel names (one per line) to the file path given as
|
||||
* the first argument. Clack renders to the terminal normally.
|
||||
*
|
||||
* If NANOCLAW_CHANNELS env var is set (comma-separated names), skips the
|
||||
* prompt and writes those directly.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/select-channels.ts <output-file>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
const CHANNELS = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'discord', label: 'Discord' },
|
||||
{ value: 'slack', label: 'Slack' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'teams', label: 'Microsoft Teams' },
|
||||
{ value: 'matrix', label: 'Matrix' },
|
||||
{ value: 'imessage', label: 'iMessage' },
|
||||
{ value: 'webex', label: 'Webex' },
|
||||
{ value: 'gchat', label: 'Google Chat' },
|
||||
{ value: 'resend', label: 'Resend (email)' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'whatsapp-cloud', label: 'WhatsApp Cloud API' },
|
||||
];
|
||||
|
||||
const VALID_NAMES = new Set(CHANNELS.map((c) => c.value));
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outFile = process.argv[2];
|
||||
if (!outFile) {
|
||||
console.error('Usage: tsx setup/migrate-v2/select-channels.ts <output-file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Non-interactive: NANOCLAW_CHANNELS="telegram,discord"
|
||||
const envChannels = process.env.NANOCLAW_CHANNELS?.trim();
|
||||
if (envChannels) {
|
||||
const names = envChannels.split(',').map((s) => s.trim()).filter((s) => VALID_NAMES.has(s));
|
||||
fs.writeFileSync(outFile, names.join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = await p.multiselect({
|
||||
message: 'Which channels do you want to set up?',
|
||||
options: CHANNELS,
|
||||
required: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(selected)) {
|
||||
fs.writeFileSync(outFile, '');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outFile, (selected as string[]).join('\n') + '\n');
|
||||
}
|
||||
|
||||
main();
|
||||
181
setup/migrate-v2/sessions.ts
Normal file
181
setup/migrate-v2/sessions.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* migrate-v2 step: sessions
|
||||
*
|
||||
* For each v1 session folder, create a proper v2 session:
|
||||
* 1. Create a sessions row in v2.db (via resolveSession)
|
||||
* 2. Initialize the session folder (inbound.db, outbound.db, outbox/)
|
||||
* 3. Write session routing so the container knows where to reply
|
||||
* 4. Copy v1 .claude/ state into v2's .claude-shared/ directory
|
||||
*
|
||||
* v1: data/sessions/<folder>/.claude/ (settings, conversation history, skills)
|
||||
* v2: data/v2-sessions/<agent_group_id>/.claude-shared/ + session folder
|
||||
*
|
||||
* v1's agent-runner-src/ is NOT copied — v2 uses a completely different
|
||||
* Bun-based agent-runner.
|
||||
*
|
||||
* Idempotent — reuses existing sessions, does not overwrite files.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/sessions.ts <v1-path>
|
||||
*/
|
||||
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 { getAllAgentGroups } from '../../src/db/agent-groups.js';
|
||||
import { getMessagingGroupsByAgentGroup } from '../../src/db/messaging-groups.js';
|
||||
import { runMigrations } from '../../src/db/migrations/index.js';
|
||||
import {
|
||||
resolveSession,
|
||||
writeSessionRouting,
|
||||
outboundDbPath,
|
||||
} from '../../src/session-manager.js';
|
||||
|
||||
const SKIP_NAMES = new Set(['.DS_Store']);
|
||||
|
||||
/** Recursively copy, never overwriting existing files. */
|
||||
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;
|
||||
}
|
||||
if (fs.existsSync(d)) continue;
|
||||
fs.copyFileSync(s, d);
|
||||
written += 1;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const v1Path = process.argv[2];
|
||||
if (!v1Path) {
|
||||
console.error('Usage: tsx setup/migrate-v2/sessions.ts <v1-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1SessionsDir = path.join(v1Path, 'data', 'sessions');
|
||||
if (!fs.existsSync(v1SessionsDir)) {
|
||||
console.log('SKIPPED:no v1 data/sessions/ directory');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Init v2 central DB
|
||||
const v2DbPath = path.join(DATA_DIR, 'v2.db');
|
||||
if (!fs.existsSync(v2DbPath)) {
|
||||
console.error('v2.db not found — run db step first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v2Db = initDb(v2DbPath);
|
||||
runMigrations(v2Db);
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const folderToAg = new Map<string, { id: string; folder: string }>();
|
||||
for (const ag of agentGroups) {
|
||||
folderToAg.set(ag.folder, ag);
|
||||
}
|
||||
|
||||
let sessionsCreated = 0;
|
||||
let sessionsReused = 0;
|
||||
let sessionsSkipped = 0;
|
||||
let filesCopied = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(v1SessionsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const folder = entry.name;
|
||||
|
||||
const ag = folderToAg.get(folder);
|
||||
if (!ag) {
|
||||
sessionsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the messaging groups wired to this agent group
|
||||
const messagingGroups = getMessagingGroupsByAgentGroup(ag.id);
|
||||
if (messagingGroups.length === 0) {
|
||||
sessionsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a session for each messaging group (v1 had one session per
|
||||
// folder, v2 has one per agent_group + messaging_group pair)
|
||||
for (const mg of messagingGroups) {
|
||||
const { session, created } = resolveSession(ag.id, mg.id, null, 'shared');
|
||||
|
||||
if (created) {
|
||||
// Write routing so the container knows where to reply
|
||||
writeSessionRouting(ag.id, session.id);
|
||||
sessionsCreated++;
|
||||
} else {
|
||||
sessionsReused++;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy v1 .claude/ state into v2's .claude-shared/ directory
|
||||
// This is per-agent-group, shared across all sessions for that group
|
||||
const v1ClaudeDir = path.join(v1SessionsDir, folder, '.claude');
|
||||
if (fs.existsSync(v1ClaudeDir)) {
|
||||
const v2ClaudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
|
||||
filesCopied += copyTree(v1ClaudeDir, v2ClaudeDir);
|
||||
|
||||
// v1 containers worked in /workspace/group, v2 works in /workspace/agent.
|
||||
// Claude Code stores sessions under projects/<hashed-cwd>/. Copy the v1
|
||||
// project dir to the v2 path so Claude Code finds the conversation history.
|
||||
const projectsDir = path.join(v2ClaudeDir, 'projects');
|
||||
const v1ProjectDir = path.join(projectsDir, '-workspace-group');
|
||||
const v2ProjectDir = path.join(projectsDir, '-workspace-agent');
|
||||
if (fs.existsSync(v1ProjectDir) && !fs.existsSync(v2ProjectDir)) {
|
||||
filesCopied += copyTree(v1ProjectDir, v2ProjectDir);
|
||||
}
|
||||
|
||||
// Write the v1 Claude Code session ID as the continuation in outbound.db
|
||||
// so the agent-runner resumes the exact same conversation.
|
||||
// The session ID is the JSONL filename (without extension) under the
|
||||
// project dir.
|
||||
const sourceDir = fs.existsSync(v2ProjectDir) ? v2ProjectDir : v1ProjectDir;
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
const jsonlFiles = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.jsonl'));
|
||||
if (jsonlFiles.length > 0) {
|
||||
// Use the most recent JSONL file (by mtime from v1)
|
||||
const v1SessionId = jsonlFiles
|
||||
.map((f) => ({
|
||||
name: f.replace('.jsonl', ''),
|
||||
mtime: fs.statSync(path.join(sourceDir, f)).mtimeMs,
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime)[0].name;
|
||||
|
||||
// Write into each v2 session's outbound.db for this agent group
|
||||
const sessions = getMessagingGroupsByAgentGroup(ag.id);
|
||||
for (const mg of sessions) {
|
||||
const { session } = resolveSession(ag.id, mg.id, null, 'shared');
|
||||
const obPath = outboundDbPath(ag.id, session.id);
|
||||
if (fs.existsSync(obPath)) {
|
||||
const ob = new Database(obPath);
|
||||
ob.prepare(
|
||||
"INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES ('continuation:claude', ?, ?)",
|
||||
).run(v1SessionId, new Date().toISOString());
|
||||
ob.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeDb();
|
||||
|
||||
console.log(`OK:created=${sessionsCreated},reused=${sessionsReused},skipped=${sessionsSkipped},files=${filesCopied}`);
|
||||
}
|
||||
|
||||
main();
|
||||
53
setup/migrate-v2/switchover-prompt.ts
Normal file
53
setup/migrate-v2/switchover-prompt.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* migrate-v2: service switchover prompts.
|
||||
*
|
||||
* Writes a single word to the output file:
|
||||
* --offer-switch → "switch" | "skip"
|
||||
* --keep-or-revert → "keep" | "revert"
|
||||
*
|
||||
* Clack renders to the terminal normally.
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch <output-file>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const mode = process.argv[2];
|
||||
const outFile = process.argv[3];
|
||||
|
||||
if (!outFile) {
|
||||
console.error('Usage: tsx setup/migrate-v2/switchover-prompt.ts <--offer-switch|--keep-or-revert> <output-file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (mode === '--offer-switch') {
|
||||
const answer = await p.select({
|
||||
message: 'Want to stop the v1 service and start v2 so you can test?',
|
||||
options: [
|
||||
{ value: 'switch', label: 'Yes, switch to v2 now', hint: 'you can switch back after' },
|
||||
{ value: 'skip', label: 'No, skip for now', hint: 'start v2 manually later' },
|
||||
],
|
||||
});
|
||||
fs.writeFileSync(outFile, p.isCancel(answer) ? 'skip' : String(answer));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === '--keep-or-revert') {
|
||||
const answer = await p.select({
|
||||
message: 'Keep v2 running, or switch back to v1?',
|
||||
options: [
|
||||
{ value: 'keep', label: 'Keep v2', hint: 'v1 stays stopped' },
|
||||
{ value: 'revert', label: 'Switch back to v1', hint: 'stop v2, restart v1' },
|
||||
],
|
||||
});
|
||||
fs.writeFileSync(outFile, p.isCancel(answer) ? 'revert' : String(answer));
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Usage: --offer-switch | --keep-or-revert');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
158
setup/migrate-v2/tasks.ts
Normal file
158
setup/migrate-v2/tasks.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* migrate-v2 step: tasks
|
||||
*
|
||||
* Port v1 scheduled_tasks into v2 session inbound DBs.
|
||||
*
|
||||
* v1: scheduled_tasks table (schedule_type, schedule_value, next_run)
|
||||
* v2: messages_in rows with kind='task' in per-session inbound.db
|
||||
*
|
||||
* Requires: db step must have run first (agent_groups + messaging_groups seeded).
|
||||
*
|
||||
* Usage: pnpm exec tsx setup/migrate-v2/tasks.ts <v1-path>
|
||||
*/
|
||||
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 { insertTask } from '../../src/modules/scheduling/db.js';
|
||||
import { openInboundDb, resolveSession } from '../../src/session-manager.js';
|
||||
import { parseJid, v2PlatformId } from '../migrate-v1/shared.js';
|
||||
|
||||
interface V1Task {
|
||||
id: string;
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
next_run: string | null;
|
||||
status: string;
|
||||
context_mode: string | null;
|
||||
script: string | null;
|
||||
}
|
||||
|
||||
function toCron(t: V1Task): { processAfter: string; recurrence: string | null } | null {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (t.schedule_type === 'cron') {
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const v1Path = process.argv[2];
|
||||
if (!v1Path) {
|
||||
console.error('Usage: tsx setup/migrate-v2/tasks.ts <v1-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const v1DbPath = path.join(v1Path, 'store', 'messages.db');
|
||||
if (!fs.existsSync(v1DbPath)) {
|
||||
console.log('SKIPPED:no v1 DB');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read v1 tasks
|
||||
const v1Db = new Database(v1DbPath, { readonly: true, fileMustExist: true });
|
||||
const allTasks = v1Db.prepare('SELECT * FROM scheduled_tasks').all() as V1Task[];
|
||||
v1Db.close();
|
||||
|
||||
const activeTasks = allTasks.filter((t) => t.status === 'active');
|
||||
if (activeTasks.length === 0) {
|
||||
console.log('SKIPPED:no active tasks');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Init v2 central DB
|
||||
const v2DbPath = path.join(DATA_DIR, 'v2.db');
|
||||
if (!fs.existsSync(v2DbPath)) {
|
||||
console.error('v2.db not found — run db step first');
|
||||
process.exit(1);
|
||||
}
|
||||
const v2Db = initDb(v2DbPath);
|
||||
runMigrations(v2Db);
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const t of activeTasks) {
|
||||
try {
|
||||
const ag = getAgentGroupByFolder(t.group_folder);
|
||||
if (!ag) { skipped++; continue; }
|
||||
|
||||
const parsed = parseJid(t.chat_jid);
|
||||
if (!parsed) { skipped++; continue; }
|
||||
|
||||
const platformId = v2PlatformId(parsed.channel_type, t.chat_jid);
|
||||
const mg = getMessagingGroupByPlatform(parsed.channel_type, platformId);
|
||||
if (!mg) { skipped++; continue; }
|
||||
|
||||
const scheduling = toCron(t);
|
||||
if (!scheduling) { skipped++; continue; }
|
||||
|
||||
const { session } = resolveSession(ag.id, mg.id, null, 'shared');
|
||||
const inboxDb = openInboundDb(ag.id, session.id);
|
||||
try {
|
||||
// Idempotence check
|
||||
const existing = inboxDb
|
||||
.prepare("SELECT id FROM messages_in WHERE id = ? AND kind = 'task'")
|
||||
.get(t.id) as { id: string } | undefined;
|
||||
if (existing) { skipped++; continue; }
|
||||
|
||||
insertTask(inboxDb, {
|
||||
id: t.id,
|
||||
processAfter: scheduling.processAfter,
|
||||
recurrence: scheduling.recurrence,
|
||||
platformId,
|
||||
channelType: parsed.channel_type,
|
||||
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 },
|
||||
}),
|
||||
});
|
||||
migrated++;
|
||||
} finally {
|
||||
inboxDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`TASK_ERROR:${t.id}:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
closeDb();
|
||||
console.log(`OK:active=${activeTasks.length},migrated=${migrated},skipped=${skipped},failed=${failed}`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user