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:
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",