Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit f029486a009f070e31b1f5b8ed14e932474ffa44
parent b133a4a0db4e11fa7ae95633484398c9073bb625
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 11:16:28 -0400

publish: the hub builds and deploys; the homepage deploys through the CLI

buildHub = compose:hub then INSTANCE_MODE=hub next build in export/, after
removing public/site.json; compose-site removes public/hub-sites.json in turn,
so export/out says which it holds. builtHubProblem refuses to deploy a site's
bundle as the hub. deployHub ships export/out to homepage.json's
cloudflareProject, refusing none and the homepage's own "archilyzer", with
runDeployIntoLog's deployment-URL line. buildHomepage / deployHomepage run the
homepage package's compose + next build and deploy homepage/out to the constant
project archilyzer on branch main (the line homepage/package.json hardcoded).
CLI: build hub, deploy hub [--preview], build homepage, deploy homepage.
export build:hub and homepage deploy call it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/bin/archilyzer.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-site.ts | 5+++++
Mcommon/lib/builtExport.test.ts | 31++++++++++++++++++++++++++++++-
Mcommon/lib/builtExport.ts | 24+++++++++++++++++++++++-
Mcommon/publish/build.test.ts | 36++++++++++++++++++++++++++++++++++++
Mcommon/publish/build.ts | 227++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mexport/package.json | 2+-
Mhomepage/package.json | 2+-
8 files changed, 364 insertions(+), 6 deletions(-)

diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -102,6 +102,26 @@ export const COMMANDS: Command[] = [ }, }, { + path: ["build", "hub"], + usage: "compose:hub + INSTANCE_MODE=hub next build into export/out", + run: async () => { + const { buildHub } = await import("../publish/build"); + const code = await buildHub({ signal: interrupted() }); + if (code !== 0) console.error(`build hub: failed (exit ${code})`); + return code; + }, + }, + { + path: ["build", "homepage"], + usage: "compose + next build in homepage/ (reads the index as it stands)", + run: async () => { + const { buildHomepage } = await import("../publish/build"); + const code = await buildHomepage({ signal: interrupted() }); + if (code !== 0) console.error(`build homepage: failed (exit ${code})`); + return code; + }, + }, + { path: ["deploy", "site"], usage: "<id> [--preview <branch>] ship the site built in export/out to its Pages project (default id: SITE_ID)", @@ -120,6 +140,29 @@ export const COMMANDS: Command[] = [ }, }, { + path: ["deploy", "hub"], + usage: + "[--preview <branch>] ship the hub built in export/out to homepage.json's Pages project", + flags: { preview: "string" }, + run: async ({ flags }) => { + const { deployHub } = await import("../publish/build"); + return refusalsExit(() => + deployHub({ + signal: interrupted(), + previewBranch: typeof flags.preview === "string" ? flags.preview : undefined, + }), + ); + }, + }, + { + path: ["deploy", "homepage"], + usage: "ship homepage/out to the Pages project archilyzer (production branch main)", + run: async () => { + const { deployHomepage } = await import("../publish/build"); + return refusalsExit(() => deployHomepage({ signal: interrupted() })); + }, + }, + { path: ["sync", "tick"], usage: "POST one scheduler tick to the editor (SYNC_TICK_URL, SYNC_TICK_TOKEN)", run: async () => (await import("./sync-tick")).tick(), diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -895,6 +895,11 @@ export async function main( // --- federation contract: /site.json descriptor + CORS _headers --- await emitFederationFiles(site, paths); + // A site's bundle is not a hub's. export/public is shared with the hub build, + // whose compose writes hub-sites.json; left in place it ships in this site's + // out/ and makes the bundle ambiguous to builtHubProblem (and to a site's + // own registry, which treats the file as a hub's trusted pool). + await rm(path.join(paths.exportPublicDir, "hub-sites.json"), { force: true }); // --- service worker (only when this instance ships a PWA) --- await composeServiceWorker(site, paths); diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { builtSiteIdIn, builtSiteProblem } from "./builtExport"; +import { builtHubProblem, builtSiteIdIn, builtSiteProblem } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "built-export-")); @@ -84,3 +84,32 @@ test("builtSiteProblem says nothing was built rather than naming a mismatch", () cleanup(); } }); + +// The hub shares export/out with every site build. A hub bundle carries +// hub-sites.json and no site.json (the hub build removes it); anything with a +// site.json is some site's bundle, even when a stale hub-sites.json sits beside +// it. +test("builtHubProblem accepts only a hub bundle", () => { + const hub = tempOut(); + const site = tempOut(JSON.stringify({ siteId: "jeralyzer" })); + const none = tempOut(); + try { + writeFileSync(path.join(hub.dir, "hub-sites.json"), "[]"); + assert.equal(builtHubProblem(hub.dir), null); + + writeFileSync(path.join(site.dir, "hub-sites.json"), "[]"); + assert.equal( + builtHubProblem(site.dir), + 'export/out holds a build of "jeralyzer", not the hub — build the hub first', + ); + + assert.equal( + builtHubProblem(none.dir), + "export/out holds no hub build — build the hub first", + ); + } finally { + hub.cleanup(); + site.cleanup(); + none.cleanup(); + } +}); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -13,7 +13,7 @@ // public/ into out/. So the check is a file read, and it is cheap enough to do // before every deploy. -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; /** @@ -58,3 +58,25 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null } return null; } + +/** + * Why `outDir` may not be deployed as the HUB, as one sentence — or null when + * it holds a hub build. + * + * The hub is the export app built with INSTANCE_MODE=hub into the SAME + * export/out a site build uses, so the two overwrite each other. A hub build + * names itself by what it carries and what it does not: compose-hub writes + * `hub-sites.json` and the hub build removes `site.json` first, while a site's + * compose removes `hub-sites.json` and writes `site.json`. A site.json here is + * therefore a site's bundle, whatever else is beside it. + */ +export function builtHubProblem(outDir: string): string | null { + const site = builtSiteIdIn(outDir); + if (site !== null) { + return `export/out holds a build of "${site}", not the hub — build the hub first`; + } + if (!existsSync(path.join(outDir, "hub-sites.json"))) { + return "export/out holds no hub build — build the hub first"; + } + return null; +} diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -2,7 +2,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import type { Paths } from "../lib/paths"; import { + HOMEPAGE_PAGES_PROJECT, + buildHubSteps, buildSiteSteps, + hubProjectProblem, dockerSiteOutDir, dockerSiteStagingDir, resolveOutDir, @@ -90,3 +93,36 @@ test("buildSiteSteps: skipData drops the data phase; skipArchives sets BUILD_ARC undefined, ); }); + +test("buildHubSteps: compose:hub, then next build with INSTANCE_MODE=hub, in export/", () => { + const steps = buildHubSteps({ paths, baseEnv: { PATH: "/bin" } }); + const env = { + PATH: "/bin", + NODE_ENV: "production", + TRANSCRIPTS_DIR: "/data/transcripts", + EXPORT_PUBLIC_DIR: "/repo/export/public", + }; + assert.deepEqual(steps, [ + { command: "pnpm", args: ["run", "compose:hub"], cwd: "/repo/export", env }, + { + command: "pnpm", + args: ["exec", "next", "build"], + cwd: "/repo/export", + env: { ...env, INSTANCE_MODE: "hub" }, + }, + ]); +}); + +// The hub and the homepage are two Pages projects. homepage.json's project was +// "archilyzer" — the homepage's — before the hub could deploy, so that value +// is refused by name rather than trusted. +test("hubProjectProblem refuses a missing project and the homepage's", () => { + assert.equal(HOMEPAGE_PAGES_PROJECT, "archilyzer"); + assert.equal( + hubProjectProblem(undefined), + "The hub has no Cloudflare Pages project configured — set it on /sites under Hub.", + ); + assert.equal(hubProjectProblem(" "), hubProjectProblem(undefined)); + assert.match(hubProjectProblem("archilyzer")!, /homepage's — set the hub's own project/); + assert.equal(hubProjectProblem("archilyzer-hub"), null); +}); diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -9,12 +9,13 @@ // forbids non-serializable args). Keep them as plain helpers. import path from "node:path"; -import { mkdir, readdir, stat } from "node:fs/promises"; +import { mkdir, readdir, rm, stat } from "node:fs/promises"; import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "../jobs/runChild"; -import { builtSiteProblem } from "../lib/builtExport"; +import { builtHubProblem, builtSiteProblem } from "../lib/builtExport"; +import { getHomepageConfig } from "../lib/homepage"; import { deploymentUrlIn, pagesDeployArgs, @@ -770,3 +771,225 @@ export async function buildAll( } return outcomes; } + +// --------------------------------------------------------------------------- +// The hub and the homepage — two apps, two Pages projects (decision +// 2026-09-25). +// +// The HUB is the export app built with INSTANCE_MODE=hub: a federating shell +// over every site that has a public URL, branded by homepage.json and deployed +// to homepage.json's `cloudflareProject` (`archilyzer-hub`). It builds into the +// same export/out as a site — the two overwrite each other, and +// builtHubProblem / builtSiteProblem refuse to deploy the wrong one. +// +// The HOMEPAGE is the `homepage` package: the software's own site (docs, the +// source download), deployed to the constant project `archilyzer` +// (https://archilyzer.pages.dev, PROJECT_URL). It is never the hub. +// --------------------------------------------------------------------------- + +/** The homepage package's Pages project. Constant: it is the product's site. */ +export const HOMEPAGE_PAGES_PROJECT = "archilyzer"; + +/** + * Why `project` may not be the hub's deploy target, as one sentence — or null. + * The homepage's project is refused by name: homepage.json said `archilyzer` + * until the hub got a deploy path, and a hub deployed there would replace the + * software's own site. + */ +export function hubProjectProblem(project: string | undefined): string | null { + const p = project?.trim(); + if (!p) { + return "The hub has no Cloudflare Pages project configured — set it on /sites under Hub."; + } + if (p === HOMEPAGE_PAGES_PROJECT) { + return ( + `The hub's Cloudflare Pages project is "${p}", which is the Archilyzer ` + + `homepage's — set the hub's own project (for example "archilyzer-hub") ` + + `on /sites under Hub.` + ); + } + return null; +} + +/** + * The hub build, as the children it runs: compose:hub (hub-sites.json, + * corpus.json, llms.txt, robots.txt, _headers, sw.js into export/public), then + * `next build` with INSTANCE_MODE=hub — both in export/. + */ +export function buildHubSteps(opts: { + paths: Paths; + baseEnv?: NodeJS.ProcessEnv; +}): BuildStep[] { + const { paths } = opts; + const env: NodeJS.ProcessEnv = { + ...(opts.baseEnv ?? process.env), + NODE_ENV: "production", + TRANSCRIPTS_DIR: paths.transcriptsDir, + EXPORT_PUBLIC_DIR: paths.exportPublicDir, + }; + return [ + { command: "pnpm", args: ["run", "compose:hub"], cwd: paths.exportDir, env }, + { + command: "pnpm", + args: ["exec", "next", "build"], + cwd: paths.exportDir, + env: { ...env, INSTANCE_MODE: "hub" }, + }, + ]; +} + +/** Compose the hub's export/public, as a child. Returns the exit code. */ +export async function composeHub(opts: PublishOpts = {}): Promise<number> { + const { paths, onLog, signal } = resolved(opts); + return runSteps(onLog, signal, buildHubSteps({ paths }).slice(0, 1)); +} + +/** + * Build the hub into export/out. Removes public/site.json first — a site's + * compose left it there, and a hub bundle carrying one would read as that + * site's (builtExport.ts). Returns the exit code. + */ +export async function buildHub(opts: PublishOpts = {}): Promise<number> { + const { paths, onLog, signal } = resolved(opts); + await rm(path.join(paths.exportPublicDir, "site.json"), { force: true }); + return runSteps(onLog, signal, buildHubSteps({ paths })); +} + +// One Pages deploy of `outDir` to `project`, streaming into onLog, with the +// deployment URL (or the preview alias) repeated as the last line on success +// — runDeployIntoLog's shape, for a bundle that is not a Site. +async function runPagesDeployIntoLog( + onLog: (line: string) => void, + signal: AbortSignal, + opts: { + outDir: string; + project: string; + cwd: string; + previewBranch?: string; + extraArgs?: string[]; + }, +): Promise<number> { + let deploymentUrl: string | null = null; + const watch = (line: string) => { + if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, opts.project); + onLog(line); + }; + const code = await runChildIntoLog(watch, signal, { + command: "pnpm", + args: [ + "dlx", + ...pagesDeployArgs({ + outDir: opts.outDir, + project: opts.project, + previewBranch: opts.previewBranch, + }), + ...(opts.extraArgs ?? []), + ], + cwd: opts.cwd, + env: { ...process.env, NODE_ENV: "production" }, + }); + if (code === 0) { + if (opts.previewBranch) { + const alias = previewAliasUrl(opts.project, opts.previewBranch); + onLog( + `[preview] ${alias}` + + (deploymentUrl ? ` (this deployment: ${deploymentUrl})` : "") + + "\n", + ); + } else if (deploymentUrl) { + onLog(`[deployed] ${deploymentUrl}\n`); + } + } + return code; +} + +/** + * Deploy the hub built in export/out to homepage.json's Pages project (a + * PREVIEW with `previewBranch`). THROWS every refusal and failure; returns + * quietly on a cancel. No R2 step: the hub holds no archives. + */ +export async function deployHub( + opts: PublishOpts & { previewBranch?: string } = {}, +): Promise<void> { + const { paths, onLog, signal } = resolved(opts); + if (opts.previewBranch !== undefined) { + const problem = previewBranchProblem(opts.previewBranch); + if (problem) throw new Error(problem); + } + const branch = opts.previewBranch?.trim() || undefined; + const project = getHomepageConfig(paths).cloudflareProject; + const projectProblem = hubProjectProblem(project); + if (projectProblem) throw new Error(projectProblem); + const outDir = resolveOutDir("", paths); + const builtProblem = builtHubProblem(outDir); + if (builtProblem) throw new Error(builtProblem); + if (branch) onLog(`=== Deploy hub (preview "${branch}") ===\n`); + const code = await runPagesDeployIntoLog(onLog, signal, { + outDir, + project: project!.trim(), + cwd: paths.exportDir, + previewBranch: branch, + }); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Hub deploy failed (exit ${code}).`); +} + +function homepageDir(paths: Paths): string { + return path.join(paths.monorepoRoot, "homepage"); +} + +function homepageEnv(paths: Paths): NodeJS.ProcessEnv { + return { + ...process.env, + NODE_ENV: "production", + TRANSCRIPTS_DIR: paths.transcriptsDir, + }; +} + +/** + * Compose homepage/public (whole-pool stats, channel → sites map, landing + * summary), as a child. Reads the LMDB index as it stands: run `archilyzer + * index` first when it is stale. Returns the exit code. + */ +export async function composeHomepage(opts: PublishOpts = {}): Promise<number> { + const { paths, onLog, signal } = resolved(opts); + return runSteps(onLog, signal, [ + { command: "pnpm", args: ["run", "compose"], cwd: homepageDir(paths), env: homepageEnv(paths) }, + ]); +} + +/** composeHomepage, then `next build` in homepage/ (→ homepage/out). */ +export async function buildHomepage(opts: PublishOpts = {}): Promise<number> { + const { paths, onLog, signal } = resolved(opts); + const code = await composeHomepage({ paths, onLog, signal }); + if (code !== 0) return code; + return runSteps(onLog, signal, [ + { + command: "pnpm", + args: ["exec", "next", "build"], + cwd: homepageDir(paths), + env: homepageEnv(paths), + }, + ]); +} + +/** + * Deploy homepage/out to the homepage's constant project, to its production + * branch `main` — exactly what homepage/package.json's hardcoded `deploy` line + * ran. THROWS on failure or when there is no build. + */ +export async function deployHomepage(opts: PublishOpts = {}): Promise<void> { + const { paths, onLog, signal } = resolved(opts); + const outDir = path.join(homepageDir(paths), "out"); + if (!existsSync(path.join(outDir, "index.html"))) { + throw new Error("homepage/out holds no build — run archilyzer build homepage first"); + } + const code = await runPagesDeployIntoLog(onLog, signal, { + outDir, + project: HOMEPAGE_PAGES_PROJECT, + cwd: homepageDir(paths), + extraArgs: ["--branch", "main"], + }); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Homepage deploy failed (exit ${code}).`); +} diff --git a/export/package.json b/export/package.json @@ -14,7 +14,7 @@ "compose:site": "tsx ../common/bin/compose-site.ts", "compose:hub": "tsx ../common/bin/compose-hub.ts", "build": "tsx ../common/bin/archilyzer.ts build site", - "build:hub": "pnpm run compose:hub && INSTANCE_MODE=hub next build", + "build:hub": "tsx ../common/bin/archilyzer.ts build hub", "start": "serve out", "lint": "eslint", "e2e": "node ../scripts/queue-lock.mjs --ports EXPORT_E2E_PORT:3020 -- playwright test", diff --git a/homepage/package.json b/homepage/package.json @@ -15,7 +15,7 @@ "lint": "eslint", "e2e": "node ../scripts/queue-lock.mjs --ports HOMEPAGE_E2E_PORT:3040 -- playwright test", "e2e:ui": "playwright test --ui", - "deploy": "pnpm dlx wrangler pages deploy out --project-name archilyzer --branch main" + "deploy": "tsx ../common/bin/archilyzer.ts deploy homepage" }, "dependencies": { "@tanstack/react-query": "^5.99.1",