BUCKYVERSE

Cutover (SP6) — Implementation Plan

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: Build and verify the Wiki.js→Astro redirect map, then hand the human owner a tested cutover runbook for the production DNS flip and droplet retirement.

Architecture: A build-redirects.mjs generates the full public/_redirects (SP3 dictionary rules + knowledge /en strip + generated per-book /read/<slug> rules + enumerated orphan→home prefixes), with a pure, unit-tested bookRedirectRules. A verify-redirects.mjs checks a sample of old→new paths redirect AND that real content still serves, against future.buckyverse.org (which is the buckyverse-future Pages project). The DNS flip + droplet retirement are a written runbook the human executes — NOT run by the agent.

Tech Stack: Node (mjs scripts) · Vitest · Cloudflare Pages _redirects · wrangler · curl.

Spec: docs/superpowers/specs/2026-07-06-cutover-design.md.


File structure

New (apps/reader):

Modified (apps/reader):

New (repo):


Task 1: Pure book-redirect rule generator

Files: create apps/reader/scripts/lib/bookRedirectRules.mjs, bookRedirectRules.test.mjs.

// apps/reader/scripts/lib/bookRedirectRules.test.mjs
import { describe, it, expect } from 'vitest';
import { bookRedirectRules } from './bookRedirectRules.mjs';

describe('bookRedirectRules', () => {
  const books = [
    { slug: 'grunch_of_giants', epubPath: '../../grunch_of_giants/grunch_of_giants.epub' },
    { slug: 'wdsd_2_the_design_initiative', epubPath: '../../world_design_science_decade_1965-1975/document-2-the-design-initiative/the_design_initiative.epub' },
  ];
  const dirs = ['grunch_of_giants', 'world_design_science_decade_1965-1975', 'some_unread_book'];

  it('maps a readable book dir to /read/<slug>', () => {
    const lines = bookRedirectRules(books, dirs);
    expect(lines).toContain('/grunch_of_giants\t/read/grunch_of_giants\t301');
    expect(lines).toContain('/grunch_of_giants/index\t/read/grunch_of_giants\t301');
  });
  it('maps a readable WDSD sub-page (nested dir) to its wdsd slug', () => {
    const lines = bookRedirectRules(books, dirs);
    expect(lines).toContain('/world_design_science_decade_1965-1975/document-2-the-design-initiative\t/read/wdsd_2_the_design_initiative\t301');
  });
  it('maps an unreadable book dir to /library', () => {
    const lines = bookRedirectRules(books, dirs);
    expect(lines).toContain('/some_unread_book\t/library\t301');
    expect(lines).toContain('/some_unread_book/index\t/library\t301');
  });
  it('does not emit a /library rule for a dir already covered by a /read rule', () => {
    const lines = bookRedirectRules(books, dirs);
    expect(lines.some((l) => l.startsWith('/grunch_of_giants\t/library'))).toBe(false);
  });
});
// apps/reader/scripts/lib/bookRedirectRules.mjs
// Pure: readable books -> /read/<slug> rules (keyed on the epub's containing dir);
// every other top-level book dir -> /library. Rows are TAB-separated `path\ttarget\t301`.

/** epubPath '../../a/b/x.epub' -> repo-relative dir 'a/b'. */
function dirFromEpub(epubPath) {
  return epubPath.replace(/^(\.\.\/)+/, '').replace(/\/[^/]+\.epub$/, '');
}

/**
 * @param {{slug:string, epubPath:string}[]} books  readable books (books.config)
 * @param {string[]} topLevelBookDirs  repo-root book/source dir names
 * @returns {string[]} redirect rule lines
 */
export function bookRedirectRules(books, topLevelBookDirs) {
  const lines = [];
  const coveredTop = new Set();
  for (const b of books) {
    const dir = dirFromEpub(b.epubPath); // 'grunch_of_giants' or 'world_.../document-2-...'
    lines.push(`/${dir}\t/read/${b.slug}\t301`);
    lines.push(`/${dir}/index\t/read/${b.slug}\t301`);
    coveredTop.add(dir.split('/')[0]);
  }
  for (const dir of topLevelBookDirs) {
    if (coveredTop.has(dir)) continue; // a readable book already claimed this top-level dir
    lines.push(`/${dir}\t/library\t301`);
    lines.push(`/${dir}/index\t/library\t301`);
  }
  return lines;
}
cd /Volumes/Storage/belmendo/Projects/buckyverse.org
git add apps/reader/scripts/lib/bookRedirectRules.mjs apps/reader/scripts/lib/bookRedirectRules.test.mjs
git commit -m "feat(cutover): pure book-redirect rule generator"

Task 2: Assemble the full _redirects

Files: create apps/reader/scripts/build-redirects.mjs; modify package.json; regenerate public/_redirects.

// apps/reader/scripts/build-redirects.mjs
// Regenerate public/_redirects: dictionary (SP3) + knowledge /en strip + generated
// per-book rules + enumerated orphan->home prefixes. Order = priority (first wins):
// precise content rules BEFORE orphan catch-alls.
import { readFile, writeFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { bookRedirectRules } from './lib/bookRedirectRules.mjs';

const READER = process.cwd();                 // apps/reader
const REPO = path.resolve(READER, '../..');

const NON_BOOK = new Set([
  'node_modules', 'apps', 'docs', 'work', 'scripts', 'dev', '.wiki', '.git',
  'library', 'articles', 'relationship_graph', 'synergetics-dictionary', 'synergetics_dictionary',
]);

async function readBooks() {
  const src = await readFile(path.join(READER, 'books.config.ts'), 'utf8');
  const books = [];
  const re = /\{\s*slug:\s*'([^']+)',\s*epubPath:\s*'([^']+)'/g;
  for (const m of src.matchAll(re)) books.push({ slug: m[1], epubPath: m[2] });
  return books;
}

async function topLevelBookDirs() {
  const ents = await readdir(REPO, { withFileTypes: true });
  return ents
    .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !NON_BOOK.has(e.name))
    .map((e) => e.name)
    .sort();
}

const DICTIONARY = `# Synergetics Dictionary (Wiki.js -> Astro).
/synergetics-dictionary/:letter/index            /dictionary/:letter            301
/synergetics-dictionary/:letter/:card            /dictionary/:letter#:card      301
/en/synergetics-dictionary/:letter/index         /dictionary/:letter            301
/en/synergetics-dictionary/:letter/:card         /dictionary/:letter#:card      301
/synergetics-dictionary/How-to-Use-the-Synergetics-Dictionary   /dictionary/about/how-to-use   301
/synergetics_dictionary/*                        /dictionary/                   301`;

const KNOWLEDGE = `# Knowledge (projector /en/knowledge -> /knowledge identity on Astro).
/en/knowledge/*                                  /knowledge/:splat              301`;

const SYNERGETICS_BOOK = `# Synergetics book pages -> the reader entry.
/synergetics                                     /read/synergetics              301
/synergetics/*                                   /read/synergetics              301`;

const ORPHANS = `# Orphans (internal/dev + un-rendered projector paths) -> home. No dead ends.
/receipts/*        /   301
/codexis/*         /   301
/cursor/*          /   301
/docs/*            /   301
/docs-site/*       /   301
/work/*            /   301
/zotero/*          /   301
/buckyverse2/*     /   301
/knowledge/raw/*        /   301
/knowledge/inventory/*  /   301`;

const books = await readBooks();
const dirs = await topLevelBookDirs();
const bookLines = bookRedirectRules(books, dirs).join('\n');

const out = [
  '# GENERATED by scripts/build-redirects.mjs — do not hand-edit; edit the script.',
  DICTIONARY, '', KNOWLEDGE, '', SYNERGETICS_BOOK, '',
  '# Book / source pages (generated from books.config + repo book dirs).',
  bookLines, '',
  ORPHANS, '',
].join('\n') + '\n';

await writeFile(path.join(READER, 'public', '_redirects'), out);
console.log(`_redirects: ${out.split('\n').filter((l) => l && !l.startsWith('#')).length} rules (${books.length} readable books, ${dirs.length} book dirs)`);
git add apps/reader/scripts/build-redirects.mjs apps/reader/package.json apps/reader/public/_redirects
git commit -m "feat(cutover): generate full Wiki.js->Astro redirect map"

Task 3: Live verification script

Files: create apps/reader/scripts/verify-redirects.mjs.

// apps/reader/scripts/verify-redirects.mjs
// Check, against a live Pages host, that (a) sample old Wiki.js paths redirect to the
// right new path, and (b) real content pages still SERVE (200) rather than being
// shadowed by a redirect. Exit non-zero on any failure. Usage:
//   node scripts/verify-redirects.mjs [https://future.buckyverse.org]
import { execFileSync } from 'node:child_process';

const BASE = (process.argv[2] || 'https://future.buckyverse.org').replace(/\/$/, '');

// [path, expectedStatusRegex, expectedLocationSubstr|null]
const REDIRECTS = [
  ['/synergetics-dictionary/a/C00280', '30[12]', '/dictionary/a#C00280'],
  ['/en/knowledge/topics/synergetics', '30[12]', '/knowledge/topics/synergetics'],
  ['/grunch_of_giants', '30[12]', '/read/grunch_of_giants'],
  ['/world_design_science_decade_1965-1975/document-2-the-design-initiative', '30[12]', '/read/wdsd_2_the_design_initiative'],
  ['/receipts/anything', '30[12]', '/'],
  ['/codexis/whatever', '30[12]', '/'],
];
// Real content that MUST still serve (200), not be shadowed by a redirect.
const CONTENT = ['/dictionary/xyz/', '/knowledge/topics/synergetics/', '/read/grunch_of_giants/', '/library/', '/'];

function head(p) {
  const out = execFileSync('curl', ['-sI', `${BASE}${p}`], { encoding: 'utf8' });
  const status = (out.match(/HTTP\/[\d.]+\s+(\d{3})/) || [])[1] || '000';
  const loc = (out.match(/^location:\s*(.+)$/im) || [])[1]?.trim() || '';
  return { status, loc };
}

let fail = 0;
for (const [p, statusRe, locSub] of REDIRECTS) {
  const { status, loc } = head(p);
  const ok = new RegExp(`^${statusRe}$`).test(status) && (!locSub || loc.includes(locSub));
  console.log(`${ok ? 'OK ' : 'FAIL'} redirect ${p} -> ${status} ${loc}`);
  if (!ok) fail++;
}
for (const p of CONTENT) {
  const { status } = head(p);
  const ok = status === '200';
  console.log(`${ok ? 'OK ' : 'FAIL'} content ${p} -> ${status}`);
  if (!ok) fail++;
}
console.log(fail ? `\n${fail} FAILURES` : '\nAll checks passed.');
process.exit(fail ? 1 : 0);
git add apps/reader/scripts/verify-redirects.mjs apps/reader/package.json
git commit -m "feat(cutover): live redirect + content-serves verifier"

Task 4: Deploy to the Pages project and verify

Run: cd apps/reader && pnpm redirects && pnpm build
Run: cd apps/reader && node_modules/.bin/wrangler pages deploy dist --project-name buckyverse-future --branch main --commit-dirty=true
Report the deploy URL.

Run: cd apps/reader && pnpm verify-redirects https://future.buckyverse.org
Expected: All checks passed. — every sample redirect fires AND every real content page still returns 200 (this is the Cloudflare precedence proof: redirects do not shadow existing assets).
If a CONTENT check fails (a real page got redirected), the orphan/book rules are too broad — narrow them in build-redirects.mjs, regenerate, redeploy, re-verify. Do NOT proceed to the runbook until content checks pass.

git add -A apps/reader
git commit -m "chore(cutover): redirect map verified on future.buckyverse.org" || echo "nothing to commit"

Task 5: Write the human cutover runbook

Files: create dev/wikijs/CUTOVER-RUNBOOK.md. This task WRITES the checklist; it does NOT execute DNS or retirement.

# buckyverse.org Cutover Runbook (Wiki.js -> Astro)

Execute in order. Agent-built pieces (redirect map + verifier) are done and deployed
to future.buckyverse.org. Steps below are the production, human-executed cutover.

## Phase 0 — pre-flight (DONE by agent, confirm)
- [ ] `future.buckyverse.org` has full parity (SP1-5).
- [ ] SP3 dictionary image R2 upload finished (no card-image 404s).
- [ ] `pnpm --filter reader verify-redirects https://future.buckyverse.org` prints "All checks passed."

## Phase 1 — add custom domains (Cloudflare dashboard)
- [ ] Pages -> buckyverse-future -> Custom domains -> add `buckyverse.org` and `www.buckyverse.org`.
- [ ] Wait for TLS "Active". Note the DNS target Cloudflare shows.

## Phase 2 — DNS (Route 53) — pick ONE apex option
### Option A (recommended): move the buckyverse.org zone to Cloudflare
- [ ] Add the zone in Cloudflare; recreate ALL existing records (graph, future, MX, TXT, etc.).
- [ ] At the registrar, change nameservers to Cloudflare's. (Propagation up to 24-48h.)
- [ ] In Cloudflare DNS: apex `buckyverse.org` and `www` -> the Pages project (CNAME/flattening, per the Custom-domains panel).
### Option B: keep Route 53
- [ ] `www.buckyverse.org` CNAME -> `buckyverse-future.pages.dev` (canonical host).
- [ ] apex `buckyverse.org` -> redirect to `https://www.buckyverse.org` (S3 website redirect bucket + Route 53 alias, or a Cloudflare redirect). Route 53 cannot CNAME an apex to pages.dev.

## Phase 3 — verify in production
- [ ] `curl -sI https://buckyverse.org/` -> 200, `server: cloudflare`.
- [ ] `pnpm --filter reader verify-redirects https://buckyverse.org` -> "All checks passed."
- [ ] Manual spot-check: a dictionary card, a knowledge article, a book page, `/library`.

## Phase 4 — rollback window (default 7 days)
- [ ] Leave the droplet (64.23.238.243) + Wiki.js Postgres RUNNING and untouched.
- [ ] Rollback if needed = revert apex/www DNS to the droplet (Option A: change Cloudflare records; Option B: restore Route 53). Instant; no data reconciliation.

## Phase 5 — retire (after the window)
- [ ] Final backup: snapshot the droplet AND `pg_dump` the Wiki.js DB to cold storage.
- [ ] Destroy the droplet; stop the Wiki.js docker-compose.
- [ ] Remove dead DNS (`do.buckyverse.org` and any droplet A records).
- [ ] Archive `dev/wikijs/` scripts as historical (the GraphQL push/projector tooling is no longer needed).

Migration complete: buckyverse.org IS the Astro site.
git add dev/wikijs/CUTOVER-RUNBOOK.md
git commit -m "docs(cutover): human DNS + droplet-retirement runbook"

Self-review notes (coverage against the spec)

Agent scope ends at Task 4 (verified redirect map deployed to future.buckyverse.org) + Task 5 (runbook written). The DNS flip and droplet retirement (runbook Phases 1-5) are executed by the human owner, not this plan.