docs: void-migrate implementation plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 22:17:32 +10:00
parent feb700e3f6
commit bbb90c12c6

View File

@@ -0,0 +1,550 @@
# void-migrate Implementation Plan (Plan 8a)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A re-runnable Node CLI that imports Void 1 (SQLite), BookStack, Karakeep, and plans `.md` into Void 2 — idempotently, one Space per source.
**Architecture:** A `migrate/` CLI reusing Void 2's repos. Idempotency via a `migration_map` table (refs use `upsertByExternal`). Importers are plain async functions (testable with fixtures); the CLI wires them and supports `--dry-run` + `verify`.
**Tech Stack:** Node 22 ESM (`node:sqlite` built-in), Postgres, vitest (serial).
**Spec:** `docs/superpowers/specs/2026-06-04-void-migrate-design.md`
---
### Task 1: `migration_map` table + repo
**Files:** Create `lib/db/migrations/018_migration_map.sql`, `lib/db/repos/migration_map.js`; Test `tests/db/migration_map.test.js`
- [ ] **Step 1: Migration**
```sql
-- 018_migration_map.sql — idempotency ledger for void-migrate. Maps a source row
-- to the Void 2 entity it created, so re-running an import never duplicates.
CREATE TABLE migration_map (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL, -- 'void1' | 'bookstack' | 'karakeep' | 'plans'
source_id text NOT NULL, -- e.g. 'wiki_pages:42'
entity_type text NOT NULL, -- 'page' | 'project' | 'task' | 'conversation' | 'message'
entity_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, source_id, entity_type)
);
```
- [ ] **Step 2: Failing test** `tests/db/migration_map.test.js`
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as map from '../../lib/db/repos/migration_map.js';
import { randomUUID } from 'crypto';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('migration_map', () => {
it('seen() is null until record(), then returns the entity id', async () => {
const eid = randomUUID();
expect(await map.seen('void1', 'wiki_pages:1', 'page')).toBeNull();
await map.record('void1', 'wiki_pages:1', 'page', eid);
expect(await map.seen('void1', 'wiki_pages:1', 'page')).toBe(eid);
});
it('record() is idempotent on the unique key', async () => {
const eid = randomUUID();
await map.record('plans', 'a.md', 'page', eid);
await map.record('plans', 'a.md', 'page', randomUUID()); // ignored
expect(await map.seen('plans', 'a.md', 'page')).toBe(eid);
});
});
```
- [ ] **Step 3: Run → FAIL.**
- [ ] **Step 4: Implement** `lib/db/repos/migration_map.js`
```js
import { pool } from '../pool.js';
export async function seen(source, source_id, entity_type) {
const { rows: [r] } = await pool.query(
`SELECT entity_id FROM migration_map WHERE source=$1 AND source_id=$2 AND entity_type=$3`,
[source, source_id, entity_type]);
return r ? r.entity_id : null;
}
export async function record(source, source_id, entity_type, entity_id) {
await pool.query(
`INSERT INTO migration_map(source, source_id, entity_type, entity_id) VALUES($1,$2,$3,$4)
ON CONFLICT (source, source_id, entity_type) DO NOTHING`,
[source, source_id, entity_type, entity_id]);
}
```
- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): migration_map idempotency ledger`
---
### Task 2: `ensureSpace` helper
**Files:** Create `migrate/spaces.js`; Test `tests/migrate/spaces.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { ensureSpace } from '../../migrate/spaces.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
describe('ensureSpace', () => {
it('creates a space once and reuses it by slug', async () => {
const a = await ensureSpace('void1', 'Void 1');
const b = await ensureSpace('void1', 'Void 1');
expect(a).toBe(b);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `migrate/spaces.js`
```js
import { pool } from '../lib/db/pool.js';
import * as spaces from '../lib/db/repos/spaces.js';
const SYS = { kind: 'system', id: null };
// Returns the id of the space with `slug`, creating it if absent. Idempotent.
export async function ensureSpace(slug, name) {
const { rows: [r] } = await pool.query(`SELECT id FROM spaces WHERE slug=$1`, [slug]);
if (r) return r.id;
const created = await spaces.create({ slug, name }, SYS);
return created.id;
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): ensureSpace helper`
---
### Task 3: plans importer (simplest — establishes the pattern)
**Files:** Create `migrate/sources/plans.js`; Test `tests/migrate/plans.test.js` + `tests/fixtures/plans/*`
- [ ] **Step 1: Fixtures** — create `tests/fixtures/plans/alpha.md` (`# Alpha Plan\nbody one`) and `tests/fixtures/plans/beta.md` (`# Beta\nbody two`).
- [ ] **Step 2: Failing test** `tests/migrate/plans.test.js`
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { fileURLToPath } from 'url';
import { pool } from '../../lib/db/pool.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { ensureSpace } from '../../migrate/spaces.js';
import { importPlans } from '../../migrate/sources/plans.js';
const DIR = fileURLToPath(new URL('../fixtures/plans', import.meta.url));
let spaceId;
beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('plans', 'Plans'); });
describe('plans importer', () => {
it('imports each .md as a page, idempotently', async () => {
const r1 = await importPlans({ dir: DIR, spaceId });
expect(r1.created).toBe(2);
const { rows } = await pool.query(`SELECT title FROM pages WHERE space_id=$1 ORDER BY title`, [spaceId]);
expect(rows.map(r => r.title)).toEqual(['Alpha Plan', 'Beta']);
const r2 = await importPlans({ dir: DIR, spaceId }); // re-run
expect(r2.created).toBe(0);
});
it('dry-run writes nothing', async () => {
await resetDb(); await migrateUp(); const s = await ensureSpace('plans', 'Plans');
const r = await importPlans({ dir: DIR, spaceId: s, dryRun: true });
expect(r.created).toBe(2);
const { rows } = await pool.query(`SELECT count(*)::int n FROM pages WHERE space_id=$1`, [s]);
expect(rows[0].n).toBe(0);
});
});
```
- [ ] **Step 3: Run → FAIL.**
- [ ] **Step 4: Implement** `migrate/sources/plans.js`
```js
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as pages from '../../lib/db/repos/pages.js';
import * as map from '../../lib/db/repos/migration_map.js';
const SYS = { kind: 'system', id: null };
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60) || 'page';
function titleOf(body, file) {
const m = body.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : file.replace(/\.md$/, '');
}
export async function importPlans({ dir, spaceId, dryRun = false }) {
let created = 0;
for (const file of readdirSync(dir).filter(f => f.endsWith('.md')).sort()) {
if (await map.seen('plans', file, 'page')) continue;
const body = readFileSync(join(dir, file), 'utf8');
const title = titleOf(body, file);
created++;
if (dryRun) continue;
const page = await pages.create({ space_id: spaceId, slug: slugify(title), title, body_md: body }, SYS);
await map.record('plans', file, 'page', page.id);
}
return { created };
}
```
- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): plans importer`
---
### Task 4: Void 1 SQLite importer
**Files:** Create `migrate/sources/void1.js`; Test `tests/migrate/void1.test.js`
Void 1 columns (verified): `wiki_pages(id,slug,title,content,tags,parent_id,created_at,updated_at)`, `projects(id,name,description,status,...)`, `project_tasks(id,project_id,text,done,position,...)`, `project_journal(id,project_id,content,created_at)`, `conversations(id,project_id,title,created_at)`, `messages(id,conversation_id,role,content,created_at)`.
- [ ] **Step 1: Failing test** `tests/migrate/void1.test.js` (builds a fixture sqlite with `node:sqlite`)
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { pool } from '../../lib/db/pool.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { ensureSpace } from '../../migrate/spaces.js';
import { importVoid1 } from '../../migrate/sources/void1.js';
let dbPath, spaceId;
beforeAll(async () => {
await resetDb(); await migrateUp(); spaceId = await ensureSpace('void1', 'Void 1');
dbPath = join(tmpdir(), `void1-${randomUUID()}.db`);
const db = new DatabaseSync(dbPath);
db.exec(`CREATE TABLE wiki_pages(id TEXT,slug TEXT,title TEXT,content TEXT,tags TEXT,parent_id TEXT,created_at INT,updated_at INT);
CREATE TABLE projects(id TEXT,name TEXT,description TEXT,status TEXT);
CREATE TABLE project_tasks(id TEXT,project_id TEXT,text TEXT,done INT,position INT);
CREATE TABLE project_journal(id TEXT,project_id TEXT,content TEXT,created_at INT);
CREATE TABLE conversations(id TEXT,project_id TEXT,title TEXT,created_at INT);
CREATE TABLE messages(id TEXT,conversation_id TEXT,role TEXT,content TEXT,created_at INT);`);
db.prepare(`INSERT INTO wiki_pages VALUES('w1','home','Home','welcome','',NULL,0,0)`).run();
db.prepare(`INSERT INTO projects VALUES('p1','Homelab','my lab','active')`).run();
db.prepare(`INSERT INTO project_tasks VALUES('t1','p1','do a thing',0,1)`).run();
db.prepare(`INSERT INTO conversations VALUES('c1','p1','Chat 1',0)`).run();
db.prepare(`INSERT INTO messages VALUES('m1','c1','user','hi',0)`).run();
db.prepare(`INSERT INTO messages VALUES('m2','c1','assistant','hello',1)`).run();
db.close();
});
describe('void1 importer', () => {
it('maps wiki/projects/tasks/conversations/messages, idempotently', async () => {
const r1 = await importVoid1({ sqlitePath: dbPath, spaceId });
expect(r1.pages).toBeGreaterThanOrEqual(1);
expect(r1.projects).toBe(1);
expect(r1.tasks).toBe(1);
expect(r1.conversations).toBe(1);
expect(r1.messages).toBe(2);
const page = await pool.query(`SELECT title FROM pages WHERE space_id=$1 AND title='Home'`, [spaceId]);
expect(page.rows.length).toBe(1);
const r2 = await importVoid1({ sqlitePath: dbPath, spaceId }); // re-run
expect(r2.pages + r2.projects + r2.tasks + r2.conversations + r2.messages).toBe(0);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `migrate/sources/void1.js`
```js
import { DatabaseSync } from 'node:sqlite';
import { pool } from '../../lib/db/pool.js';
import * as pages from '../../lib/db/repos/pages.js';
import * as projects from '../../lib/db/repos/projects.js';
import * as tasks from '../../lib/db/repos/tasks.js';
import * as messages from '../../lib/db/repos/messages.js';
import * as map from '../../lib/db/repos/migration_map.js';
const SYS = { kind: 'system', id: null };
const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60) || 'item';
// conversations.create() has no space_id param; insert directly so migrated chats
// live in the void1 Space.
async function createConversation(title, spaceId) {
const { rows: [r] } = await pool.query(
`INSERT INTO conversations(title, space_id, metadata) VALUES($1,$2,'{}') RETURNING id`, [title || 'Conversation', spaceId]);
return r.id;
}
export async function importVoid1({ sqlitePath, spaceId, dryRun = false }) {
const db = new DatabaseSync(sqlitePath, { readOnly: true });
const n = { pages: 0, projects: 0, tasks: 0, conversations: 0, messages: 0 };
const projIds = new Map(); // void1 project id -> void2 project id
for (const w of db.prepare(`SELECT * FROM wiki_pages`).all()) {
if (await map.seen('void1', `wiki_pages:${w.id}`, 'page')) continue;
n.pages++; if (dryRun) continue;
const p = await pages.create({ space_id: spaceId, slug: slugify(w.slug || w.title), title: w.title || 'Untitled', body_md: w.content || '' }, SYS);
await map.record('void1', `wiki_pages:${w.id}`, 'page', p.id);
}
for (const pr of db.prepare(`SELECT * FROM projects`).all()) {
const existing = await map.seen('void1', `projects:${pr.id}`, 'project');
if (existing) { projIds.set(pr.id, existing); continue; }
n.projects++; if (dryRun) continue;
const created = await projects.create({ space_id: spaceId, slug: slugify(pr.name), name: pr.name || 'Project', description: pr.description || null, status: pr.status === 'archived' ? 'archived' : 'active' }, SYS);
projIds.set(pr.id, created.id);
await map.record('void1', `projects:${pr.id}`, 'project', created.id);
}
for (const t of db.prepare(`SELECT * FROM project_tasks`).all()) {
if (await map.seen('void1', `project_tasks:${t.id}`, 'task')) continue;
n.tasks++; if (dryRun) continue;
const created = await tasks.create({ space_id: spaceId, project_id: projIds.get(t.project_id) || null, title: t.text || 'Task', position: t.position ?? 0 }, SYS);
await map.record('void1', `project_tasks:${t.id}`, 'task', created.id);
}
for (const j of db.prepare(`SELECT * FROM project_journal`).all()) {
if (await map.seen('void1', `project_journal:${j.id}`, 'page')) continue;
n.pages++; if (dryRun) continue;
const p = await pages.create({ space_id: spaceId, slug: slugify(`journal-${j.id}`), title: `Journal ${j.id}`, body_md: j.content || '' }, SYS);
await map.record('void1', `project_journal:${j.id}`, 'page', p.id);
}
for (const c of db.prepare(`SELECT * FROM conversations`).all()) {
let convId = await map.seen('void1', `conversations:${c.id}`, 'conversation');
if (!convId) {
n.conversations++; if (dryRun) continue;
convId = await createConversation(c.title, spaceId);
await map.record('void1', `conversations:${c.id}`, 'conversation', convId);
}
for (const m of db.prepare(`SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at`).all(c.id)) {
if (await map.seen('void1', `messages:${m.id}`, 'message')) continue;
n.messages++; if (dryRun) continue;
const appended = await messages.append(convId, { role: m.role === 'assistant' ? 'assistant' : 'user', body: m.content || '' });
await map.record('void1', `messages:${m.id}`, 'message', appended.id);
}
}
db.close();
return n;
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): Void 1 SQLite importer`
---
### Task 5: Karakeep importer (refs via upsertByExternal)
**Files:** Create `migrate/sources/karakeep.js`; Test `tests/migrate/karakeep.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { pool } from '../../lib/db/pool.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { ensureSpace } from '../../migrate/spaces.js';
import { importKarakeep } from '../../migrate/sources/karakeep.js';
let spaceId;
beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('bookmarks', 'Bookmarks'); });
describe('karakeep importer', () => {
it('imports bookmarks as url refs, idempotently', async () => {
const page = { bookmarks: [
{ id: 'b1', content: { type: 'link', url: 'https://a.test', title: 'A' } },
{ id: 'b2', content: { type: 'link', url: 'https://b.test', title: 'B' } }
], nextCursor: null };
const fetchImpl = vi.fn(async () => ({ ok: true, json: async () => page }));
const r1 = await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl });
expect(r1.created).toBe(2);
const { rows } = await pool.query(`SELECT url FROM refs WHERE space_id=$1 ORDER BY url`, [spaceId]);
expect(rows.map(r => r.url)).toEqual(['https://a.test', 'https://b.test']);
const r2 = await importKarakeep({ apiUrl: 'http://k', token: 't', spaceId, fetchImpl }); // upsert, no dupes
const { rows: again } = await pool.query(`SELECT count(*)::int n FROM refs WHERE space_id=$1`, [spaceId]);
expect(again[0].n).toBe(2);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `migrate/sources/karakeep.js`
```js
import * as refs from '../../lib/db/repos/refs.js';
const SYS = { kind: 'system', id: null };
// Karakeep REST: GET /api/v1/bookmarks?cursor=... → { bookmarks:[{id,content:{url,title}}], nextCursor }.
export async function importKarakeep({ apiUrl = process.env.KARAKEEP_URL, token = process.env.KARAKEEP_TOKEN, spaceId, dryRun = false, fetchImpl = fetch }) {
let cursor = null, created = 0;
do {
const u = `${apiUrl}/api/v1/bookmarks?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
const res = await fetchImpl(u, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`karakeep ${res.status}`);
const body = await res.json();
for (const b of (body.bookmarks || [])) {
const url = b.content?.url; if (!url) continue;
created++;
if (dryRun) continue;
await refs.upsertByExternal({ space_id: spaceId, kind: 'url', url, title: b.content?.title || url, source_kind: 'karakeep', external_id: String(b.id) }, SYS);
}
cursor = body.nextCursor || null;
} while (cursor);
return { created };
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): Karakeep bookmarks importer`
---
### Task 6: BookStack importer
**Files:** Create `migrate/sources/bookstack.js`; Test `tests/migrate/bookstack.test.js`
- [ ] **Step 1: Failing test**
```js
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { pool } from '../../lib/db/pool.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { ensureSpace } from '../../migrate/spaces.js';
import { importBookstack } from '../../migrate/sources/bookstack.js';
let spaceId;
beforeAll(async () => { await resetDb(); await migrateUp(); spaceId = await ensureSpace('wiki', 'Wiki'); });
describe('bookstack importer', () => {
it('imports pages with markdown body, idempotently', async () => {
const list = { data: [{ id: 1, name: 'Page One' }, { id: 2, name: 'Page Two' }] };
const detail = (id) => ({ id, name: id === 1 ? 'Page One' : 'Page Two', markdown: `# P${id}\nbody`, html: `<p>body</p>` });
const fetchImpl = vi.fn(async (u) => {
if (u.endsWith('/api/pages')) return { ok: true, json: async () => list };
const id = Number(u.split('/').pop());
return { ok: true, json: async () => detail(id) };
});
const r1 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl });
expect(r1.created).toBe(2);
const { rows } = await pool.query(`SELECT title FROM pages WHERE space_id=$1 ORDER BY title`, [spaceId]);
expect(rows.map(r => r.title)).toEqual(['Page One', 'Page Two']);
const r2 = await importBookstack({ apiUrl: 'http://bs', tokenId: 'i', tokenSecret: 's', spaceId, fetchImpl });
expect(r2.created).toBe(0);
});
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `migrate/sources/bookstack.js`
```js
import * as pages from '../../lib/db/repos/pages.js';
import * as map from '../../lib/db/repos/migration_map.js';
const SYS = { kind: 'system', id: null };
const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 60) || 'page';
// BookStack REST: list GET /api/pages → {data:[{id,name}]}; detail GET /api/pages/:id
// → {id,name,markdown,html}. Auth header: "Token <id>:<secret>".
export async function importBookstack({ apiUrl = process.env.BOOKSTACK_URL, tokenId = process.env.BOOKSTACK_TOKEN_ID, tokenSecret = process.env.BOOKSTACK_TOKEN_SECRET, spaceId, dryRun = false, fetchImpl = fetch }) {
const headers = { Authorization: `Token ${tokenId}:${tokenSecret}` };
const listRes = await fetchImpl(`${apiUrl}/api/pages`, { headers });
if (!listRes.ok) throw new Error(`bookstack list ${listRes.status}`);
const { data = [] } = await listRes.json();
let created = 0;
for (const item of data) {
if (await map.seen('bookstack', `page:${item.id}`, 'page')) continue;
created++; if (dryRun) continue;
const dRes = await fetchImpl(`${apiUrl}/api/pages/${item.id}`, { headers });
if (!dRes.ok) throw new Error(`bookstack page ${item.id} ${dRes.status}`);
const d = await dRes.json();
const body = d.markdown && d.markdown.trim() ? d.markdown : (d.html || '');
const p = await pages.create({ space_id: spaceId, slug: slugify(d.name), title: d.name || 'Untitled', body_md: body }, SYS);
await map.record('bookstack', `page:${item.id}`, 'page', p.id);
}
return { created };
}
```
- [ ] **Step 4: Run → PASS. Commit** `feat(migrate): BookStack importer`
---
### Task 7: CLI + verify
**Files:** Create `migrate/cli.js`, `migrate/verify.js`; Test `tests/migrate/verify.test.js`
- [ ] **Step 1: `migrate/verify.js`** — counts migrated rows per source/type from `migration_map` + refs:
```js
import { pool } from '../lib/db/pool.js';
export async function verify() {
const { rows: map } = await pool.query(
`SELECT source, entity_type, count(*)::int n FROM migration_map GROUP BY source, entity_type ORDER BY source, entity_type`);
const { rows: [{ n: bookmarks }] } = await pool.query(`SELECT count(*)::int n FROM refs WHERE source_kind='karakeep'`);
return { map, bookmarks };
}
```
- [ ] **Step 2: Verify test** `tests/migrate/verify.test.js`
```js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as map from '../../lib/db/repos/migration_map.js';
import { verify } from '../../migrate/verify.js';
import { randomUUID } from 'crypto';
beforeAll(async () => { await resetDb(); await migrateUp(); await map.record('plans', 'a.md', 'page', randomUUID()); });
describe('verify', () => {
it('reports per-source/type counts', async () => {
const out = await verify();
expect(out.map.find(r => r.source === 'plans' && r.entity_type === 'page').n).toBe(1);
});
});
```
- [ ] **Step 3: Run verify test → FAIL then PASS** after creating `verify.js`.
- [ ] **Step 4: `migrate/cli.js`** (dispatch; manual-run entry point):
```js
import 'dotenv/config';
import { ensureSpace } from './spaces.js';
import { importPlans } from './sources/plans.js';
import { importVoid1 } from './sources/void1.js';
import { importKarakeep } from './sources/karakeep.js';
import { importBookstack } from './sources/bookstack.js';
import { verify } from './verify.js';
const [, , cmd, ...rest] = process.argv;
const dryRun = rest.includes('--dry-run');
const run = {
async plans() { return importPlans({ dir: process.env.PLANS_DIR || '/root/.claude/plans', spaceId: await ensureSpace('plans', 'Plans'), dryRun }); },
async void1() { return importVoid1({ sqlitePath: process.env.VOID1_DB || '/tmp/void1.db', spaceId: await ensureSpace('void1', 'Void 1'), dryRun }); },
async karakeep() { return importKarakeep({ spaceId: await ensureSpace('bookmarks', 'Bookmarks'), dryRun }); },
async bookstack() { return importBookstack({ spaceId: await ensureSpace('wiki', 'Wiki'), dryRun }); },
async verify() { return verify(); }
};
if (!run[cmd]) { console.error(`usage: node migrate/cli.js <plans|void1|karakeep|bookstack|verify> [--dry-run]`); process.exit(1); }
run[cmd]().then(r => { console.log(JSON.stringify(r, null, 2)); process.exit(0); })
.catch(e => { console.error('migrate failed:', e.message); process.exit(1); });
```
- [ ] **Step 5: Run → PASS. Commit** `feat(migrate): CLI dispatch + verify`
---
### Task 8: Run the migration (gated execution — owner-authorized)
**Files:** none. Requires: migration 018 applied to the void2 DB + source access.
- [ ] **Step 1: Apply 018 to prod** — deploy (`bash deploy/push.sh`; runs `018_migration_map`) OR `npm run migrate` against the void2 DB.
- [ ] **Step 2: Stage sources on the run host** (CT 311 or dev box with the void2 `DATABASE_URL`):
- `scp` Void 1's `void.db` from CT 301 → set `VOID1_DB`.
- Set `PLANS_DIR` to the plans dir.
- **BookStack:** create an API token (Settings → API Tokens) → `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`.
- **Karakeep:** create an API key → `KARAKEEP_URL`, `KARAKEEP_TOKEN`.
- [ ] **Step 3: Dry-run each**`node migrate/cli.js <src> --dry-run` → review would-create counts.
- [ ] **Step 4: Import**`node migrate/cli.js plans|void1|karakeep|bookstack`.
- [ ] **Step 5: `node migrate/cli.js verify`** — confirm counts vs sources; spot-check a few entities in the Void 2 UI (the 4 new Spaces).
- [ ] **Step 6: Memory** — record migrated counts + that Plan 8a is done; note 8b (cutover) is the remaining gate to 2.0.0.
---
## Self-Review
**Spec coverage:** migration_map→T1; ensureSpace/per-source Spaces→T2; plans→T3; void1 (wiki/projects/tasks/journal/conversations/messages)→T4; karakeep (refs upsertByExternal)→T5; bookstack→T6; CLI+dry-run+verify→T7; run/verify→T8. Idempotency tested in T3T6 (re-run → 0). Out-of-scope (8b cutover) deferred. Covered.
**Placeholder scan:** T8 env values (tokens, paths) are runtime provisioning inputs, not code gaps. No TBDs in code.
**Type consistency:** importers return `{created}` (plans/karakeep/bookstack) and `{pages,projects,tasks,conversations,messages}` (void1) — tests match. `map.seen(source,source_id,type)`/`map.record(...)` consistent T1 def + T3T6 use. `ensureSpace(slug,name)→id` consistent. Repo calls match verified signatures (`pages.create({space_id,slug,title,body_md})`, `tasks.create({space_id,project_id,title,position})`, `refs.upsertByExternal({space_id,kind,url,title,source_kind,external_id})`, `messages.append(convId,{role,body})`).