Archilyzer · Source

archilyzer

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

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

sites: Build hub / Deploy hub on /sites, ops build-hub and deploy-hub

hubActions.ts runs build-hub (build queue), deploy-hub and build-deploy-hub
(deploy queue) over the publish entry points, refusing a bad preview, a
missing or homepage project and (deploy-only) a bundle that is not the hub
before any job. HubBuildButtons sits under the Hub form: Build hub with
Deploy after build (off by default), Deploy hub, lanes titled Build hub /
Build & deploy hub / Deploy hub. The Hub section's copy says the hub and the
homepage are two projects. /api/ops/build-hub ({deploy?, preview?}) and
/api/ops/deploy-hub ({preview?}) with pnpm ops build-hub|deploy-hub;
ops-api.spec pins the three deploy refusals. Hub URL examples name
https://archilyzer-hub.pages.dev (the Hub form, the site Hub URL hint,
settings homepageUrl + SETTINGS.md regenerated, mcp/README.md).

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

Diffstat:
MSETTINGS.md | 2+-
Mcommon/lib/settingsSchema.ts | 2+-
Aeditor/app/api/ops/build-hub/route.ts | 30++++++++++++++++++++++++++++++
Aeditor/app/api/ops/deploy-hub/route.ts | 36++++++++++++++++++++++++++++++++++++
Meditor/app/sites/components/HomepageConfigForm.tsx | 3++-
Aeditor/app/sites/components/HubBuildButtons.tsx | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/components/SiteForm.tsx | 2+-
Aeditor/app/sites/lib/hubActions.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/page.tsx | 25++++++++++++++++---------
Meditor/e2e/ops-api.spec.ts | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mmcp/README.md | 4++--
Mscripts/archilyzer-ops.mjs | 11+++++++++++
Mscripts/archilyzer-ops.test.mjs | 15+++++++++++++++
13 files changed, 345 insertions(+), 16 deletions(-)

diff --git a/SETTINGS.md b/SETTINGS.md @@ -483,7 +483,7 @@ Default: ## `homepageUrl` -Absolute public URL of the family hub/homepage (e.g. "https://archilyzer.pages.dev"). Every export site links back to it ("the family" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL. +Absolute public URL of the family hub (e.g. "https://archilyzer-hub.pages.dev"). Every export site links back to it ("the family" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL. Default: `""` diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -1515,7 +1515,7 @@ export const siteSettingsSchema = z.object({ "Default social links applied to every site that doesn't define its own. A site inherits these unless its site.json carries an explicit `socialLinks` array — see Site.socialLinks / resolveSocialLinks in common/lib/site.ts. The one presentation field that lives globally so a shared footer doesn't have to be repeated per site.", ), homepageUrl: settingsField((v): string => normalizeHomepageUrl(v)).describe( - "Absolute public URL of the family hub/homepage (e.g. \"https://archilyzer.pages.dev\"). Every export site links back to it (\"the family\" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL.", + "Absolute public URL of the family hub (e.g. \"https://archilyzer-hub.pages.dev\"). Every export site links back to it (\"the family\" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL.", ), savedVideoBackup: settingsField((v): SavedVideoBackupSettings => sanitizeSavedVideoBackup(v)).describe( "Backup configuration for the saved-video store (Phase 4 of the video-persistence feature). When enabled with a destination, the store is mirrored there (additively, no deletes) with a per-backup manifest, and the sync scheduler runs the backup on the configured cadence. See common/controller/backupSavedVideos.ts.", diff --git a/editor/app/api/ops/build-hub/route.ts b/editor/app/api/ops/build-hub/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { buildAndDeployHubAction, buildHubAction } from "../../../sites/lib/hubActions"; +import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { deploy?: boolean, preview? } -> { ok: true, jobId } +// +// Builds the HUB (the export app under INSTANCE_MODE=hub) into export/out — the +// same directory a site build uses. `deploy: true` makes it the one-job +// build-then-deploy (`/sites` → Hub, "Deploy after build"), which refuses a +// missing or wrong Pages project BEFORE building; `preview` needs `deploy`. +export async function POST(request: Request) { + return ops(request, ["deploy", "preview"], async (body) => { + const deploy = optBool(body, "deploy") === true; + const preview = optPreviewBranch(body); + if (preview !== undefined && !deploy) { + throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing'); + } + return jobResponse( + deploy + ? await buildAndDeployHubAction(preview ? { previewBranch: preview } : undefined) + : await buildHubAction(), + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/api/ops/deploy-hub/route.ts b/editor/app/api/ops/deploy-hub/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { deployHubAction } from "../../../sites/lib/hubActions"; +import { jobResponse, ops, optPreviewBranch } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { preview? } -> { ok: true, jobId, previewUrl? } +// +// DEPLOY ONLY — the hub already built in export/out, to homepage.json's +// Cloudflare Pages project. Refused before any job when there is no project, +// when the project is the homepage's own ("archilyzer"), or when export/out +// holds a site rather than the hub. `preview` is a branch name, as on +// deploy-site; the alias is knowable before the job runs. +export async function POST(request: Request) { + return ops(request, ["preview"], async (body) => { + const preview = optPreviewBranch(body); + const res = jobResponse( + await deployHubAction(preview ? { previewBranch: preview } : undefined), + ); + if (!preview || res.status !== 200) return res; + // Only reached once the job started, which the action does only with a + // usable project — so this read cannot be what fails. + const project = getHomepageConfig(getPaths()).cloudflareProject; + const payload = (await res.json()) as Record<string, unknown>; + return NextResponse.json( + project ? { ...payload, previewUrl: previewAliasUrl(project, preview) } : payload, + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -49,7 +49,7 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { <input className={input} name="siteUrl" - placeholder="https://archilyzer.pages.dev" + placeholder="https://archilyzer-hub.pages.dev" defaultValue={config.siteUrl ?? ""} /> </label> @@ -58,6 +58,7 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { <input className={input} name="cloudflareProject" + placeholder="archilyzer-hub" defaultValue={config.cloudflareProject ?? ""} /> </label> diff --git a/editor/app/sites/components/HubBuildButtons.tsx b/editor/app/sites/components/HubBuildButtons.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { + buildAndDeployHubAction, + buildHubAction, + deployHubAction, +} from "../lib/hubActions"; +import { JobLane } from "./JobLane"; + +type Lane = { kind: "build" | "build-deploy" | "deploy"; key: number }; + +const TITLE: Record<Lane["kind"], string> = { + build: "Build hub", + "build-deploy": "Build & deploy hub", + deploy: "Deploy hub", +}; + +// The hub's build and deploy — the export app built with INSTANCE_MODE=hub into +// export/out, then deployed to the Cloudflare Pages project set in the form +// above. One lane at a time, like BuildAllSitesButton: each launch replaces the +// previous lane (its job keeps running and stays on /jobs). +// +// "Deploy after build" starts unchecked here, unlike the all-sites batch: a +// hub deploy replaces a public site, and the first one should be a decision. +export function HubBuildButtons({ project }: { project: string | null }) { + const [deploy, setDeploy] = useState(false); + const [lane, setLane] = useState<Lane | null>(null); + const [run, setRun] = useState(0); + + function launch(kind: Lane["kind"]) { + const next = run + 1; + setRun(next); + setLane({ kind, key: next }); + } + + return ( + <div role="group" aria-label="Hub build" className="flex flex-col gap-3"> + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + onClick={() => launch(deploy ? "build-deploy" : "build")} + className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90" + > + Build hub + </button> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input + type="checkbox" + checked={deploy} + onChange={(e) => setDeploy(e.target.checked)} + /> + Deploy after build + </label> + <button + type="button" + onClick={() => launch("deploy")} + className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted" + > + Deploy hub + </button> + </div> + <p className="text-xs text-muted-foreground"> + {project + ? `Builds into export/out (shared with site builds) and deploys to the Pages project "${project}". Deploy hub ships what is already built.` + : "Builds into export/out (shared with site builds). Set a Cloudflare Pages project above to deploy."} + </p> + {lane && ( + <JobLane + key={lane.key} + title={TITLE[lane.kind]} + subtitle="INSTANCE_MODE=hub · export/out" + trigger={() => + lane.kind === "build" + ? buildHubAction() + : lane.kind === "build-deploy" + ? buildAndDeployHubAction() + : deployHubAction() + } + /> + )} + </div> + ); +} diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -248,7 +248,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { label="Hub URL" name="hubUrl" defaultValue={initial.hubUrl ?? ""} - hint="The hub this site belongs under (e.g. https://archilyzer.pages.dev). Shows a 'Hub' backlink and lets the hub recognize this site as a member. Leave blank to inherit the family default from Settings." + hint="The hub this site belongs under (e.g. https://archilyzer-hub.pages.dev). Shows a 'Hub' backlink and lets the hub recognize this site as a member. Leave blank to inherit the family default from Settings." /> <label className="flex items-center gap-2 text-sm"> <input diff --git a/editor/app/sites/lib/hubActions.ts b/editor/app/sites/lib/hubActions.ts @@ -0,0 +1,101 @@ +"use server"; + +import { builtHubProblem } from "yt-dlp-transcript-common/lib/builtExport"; +import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; +import { + buildHub, + deployHub, + hubProjectProblem, + resolveOutDir, +} from "yt-dlp-transcript-common/publish/build"; + +// The hub's build and deploy, as jobs. The hub is the export app built with +// INSTANCE_MODE=hub into the SAME export/out a site build uses, so its build +// takes the build queue (it would race a site build for export/) and its +// deploys take the deploy queue — the queues build-export and deploy-export +// use (buildAction.ts / deployAction.ts). +const BUILD_QUEUE = "build"; +const DEPLOY_QUEUE = "deploy"; + +// Refusals a deploy can give BEFORE any job starts: a bad preview name, no +// project (or the homepage's), and — for a deploy-only — no hub in export/out. +// deployHub checks them again when its job runs, because a queued deploy can +// start after a site build has replaced export/out. +function deployRefusal( + paths: Paths, + previewBranch: string | undefined, + checkBuilt: boolean, +): string | null { + if (previewBranch !== undefined) { + const problem = previewBranchProblem(previewBranch); + if (problem) return problem; + } + const projectProblem = hubProjectProblem(getHomepageConfig(paths).cloudflareProject); + if (projectProblem) return projectProblem; + if (checkBuilt) return builtHubProblem(resolveOutDir("", paths)); + return null; +} + +export async function buildHubAction(): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "build-hub", + queueKey: BUILD_QUEUE, + paths, + fn: async (onLog, signal) => { + const code = await buildHub({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Hub build failed (exit ${code})`); + }, + }); +} + +// Deploy the hub already built in export/out. With `opts.previewBranch` it is +// a Cloudflare Pages PREVIEW of the hub's project. +export async function deployHubAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + const refusal = deployRefusal(paths, opts?.previewBranch, true); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "deploy-hub", + queueKey: DEPLOY_QUEUE, + paths, + fn: (onLog, signal) => deployHub({ paths, onLog, signal, previewBranch: branch }), + }); +} + +// Build the hub and, only if that succeeds, deploy it — one job, one log, one +// Cancel, on the deploy queue (as build-deploy is for a site). +export async function buildAndDeployHubAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + // Before the build: learning the project is wrong after a build wastes it. + const refusal = deployRefusal(paths, opts?.previewBranch, false); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "build-deploy-hub", + queueKey: DEPLOY_QUEUE, + paths, + fn: async (onLog, signal) => { + onLog("=== Build hub ===\n"); + const code = await buildHub({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) { + throw new Error(`Hub build failed (exit ${code}) — not deploying.`); + } + onLog("\n=== Deploy hub ===\n"); + await deployHub({ paths, onLog, signal, previewBranch: branch }); + }, + }); +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -21,6 +21,7 @@ import { BuildModeToggle } from "./components/BuildModeToggle"; import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; import { HomepageConfigForm } from "./components/HomepageConfigForm"; +import { HubBuildButtons } from "./components/HubBuildButtons"; import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions"; export const dynamic = "force-dynamic"; @@ -29,8 +30,9 @@ export const metadata: Metadata = { title: "Sites" }; // Which live jobs the Pool section lists: the eight kinds the Pool's own // buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only // those. build-export and build-deploy are a site's Publish tab's, build-all -// and build-deploy-all are the batch panel's above — each has its own console -// and is not repeated here. +// and build-deploy-all are the batch panel's above, build-hub / deploy-hub / +// build-deploy-hub the Hub section's — each has its own console and is not +// repeated here. const BUILD_KINDS = new Set([ "build-index", "build-stats", @@ -71,6 +73,7 @@ export default async function SitesPage() { // Through the one builder, so this list has the same progress bars /jobs does // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind)); + const hubConfig = getHomepageConfig(paths); return ( <div className="flex flex-col gap-8"> @@ -183,15 +186,19 @@ export default async function SitesPage() { <div> <h2 className="text-lg font-semibold">Hub</h2> <p className="mt-1 text-sm text-muted-foreground"> - The <code>homepage</code> package builds Archilyzer&rsquo;s own site — - the marketing home, the documentation and the source download. Its{" "} - <code>/stats/</code> route is a cross-site dashboard built from the - whole channel pool. Only the operator-facing bits are edited here: the - wordmark and page title are the product&rsquo;s own, but these social - links and the deploy target are yours. + The hub is the export app built in hub mode: one search over every + site that has a public URL, reading each archive where it is + published. This config names and brands it, and its Public URL and + Cloudflare Pages project are the hub&rsquo;s own (for example{" "} + <code>archilyzer-hub</code>). The <code>homepage</code> package — + Archilyzer&rsquo;s own site, with the docs and the source download + — shares these social links and links to the hub, but deploys to + its own project, <code>archilyzer</code>, with{" "} + <code>archilyzer deploy homepage</code>. </p> </div> - <HomepageConfigForm config={getHomepageConfig(paths)} /> + <HomepageConfigForm config={hubConfig} /> + <HubBuildButtons project={hubConfig.cloudflareProject ?? null} /> </section> {/* The shared pool: corpus-wide, no site involved. */} diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -26,7 +26,7 @@ // it is a `finally`, never a trailing line: one failed assertion with the token // still unset leaves every later /api/ops and /api/worker spec answering 503. -import { readdir, rm } from "node:fs/promises"; +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { test, expect, type APIRequestContext } from "@playwright/test"; import { baseUrl } from "./baseUrl"; import { @@ -957,3 +957,47 @@ test("build-site with a bare siteId starts one build-export job", async ({ }) .toBe("build-export"); }); + +// The hub's deploy path (release 7). Every refusal here is answered BEFORE a +// job exists, which is what lets a runbook's `pnpm ops deploy-hub --wait` fail +// fast instead of queueing a deploy that can only fail. +test("deploy-hub refuses no project, the homepage's project, and a bundle that is not the hub", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + const before = await listJobIds(); + + // The fixture has no homepage.json at all: no project. + const none = await ops(request, "deploy-hub", {}); + expect(none.status).toBe(400); + expect(none.body.error).toBe( + "The hub has no Cloudflare Pages project configured — set it on /sites under Hub.", + ); + + // The homepage's project is refused by name — homepage.json said + // "archilyzer" before the hub could deploy, and a hub deployed there would + // replace the software's own site. + const hubFile = resolvePath("test-transcripts/sites/_homepage/homepage.json"); + await mkdir(resolvePath("test-transcripts/sites/_homepage"), { recursive: true }); + await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer" })); + const homepages = await ops(request, "deploy-hub", {}); + expect(homepages.status).toBe(400); + expect(homepages.body.error).toContain('"archilyzer", which is the Archilyzer homepage\'s'); + + // A real project, but export/out holds no hub: never built here, or a + // site's bundle (shared directory). + await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer-hub" })); + const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" }); + expect(unbuilt.status).toBe(400); + expect(unbuilt.body.error).toMatch( + /export\/out holds (no hub build|a build of ".*", not the hub) — build the hub first/, + ); + + // build-hub: a preview without a deploy is a mistake, not a build. + const previewOnly = await ops(request, "build-hub", { preview: "hub-check" }); + expect(previewOnly.status).toBe(400); + expect(previewOnly.body.error).toContain('"preview" needs "deploy": true'); + + expect(await listJobIds()).toEqual(before); +}); diff --git a/mcp/README.md b/mcp/README.md @@ -454,7 +454,7 @@ means "this video's URL" in the output. ``` list_channels # the default corpus list_channels source="remote:https://rekietalyzer.pages.dev" -search_transcripts query="k cups" source="hub:https://archilyzer.pages.dev#jeralyzer,rekietalyzer" +search_transcripts query="k cups" source="hub:https://archilyzer-hub.pages.dev#jeralyzer,rekietalyzer" resolve_source source="jeralyzer" # → remote:https://jeralyzer.pages.dev ``` @@ -505,7 +505,7 @@ the TypeScript path alias resolves): pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --remote https://rekietalyzer.pages.dev # a hub, federating every member site -pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --hub https://archilyzer.pages.dev +pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --hub https://archilyzer-hub.pages.dev # local shards on disk pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --local ../export/public diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -34,6 +34,9 @@ // pnpm ops build-site --json '{"siteId":"anilyzer"}' --wait // pnpm ops build-deploy --json '{"siteIds":["anilyzer","jeralyzer"]}' --wait // pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"tags-exclude"}' --wait +// pnpm ops build-hub --wait +// pnpm ops build-hub --json '{"deploy":true}' --wait +// pnpm ops deploy-hub --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json @@ -98,6 +101,10 @@ const ACTIONS = [ // one a preview is for: build once, look at the preview, then ship the same // bundle to production without rebuilding it. "deploy-site", + // The HUB (the export app in hub mode, into the same export/out a site + // build uses) and its deploy to homepage.json's Pages project. + "build-hub", + "deploy-hub", "relocate", "relocate-back", "evict-clips", @@ -289,6 +296,10 @@ export function usage() { " alone. The alias is printed after the response. Lowercase letters,", ' digits and dashes, up to 28 characters; "main" is refused.', "", + 'build-hub builds the hub into export/out; {"deploy": true} deploys it', + ' after, and deploy-hub ships the one already built. Both deploy to the', + " Pages project set on /sites under Hub, and take \"preview\" too.", + "", "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,", " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")", ].join("\n"); diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -316,3 +316,18 @@ test("--wait-timeout implies --wait", () => { assert.equal(p.wait, true); assert.equal(p.waitTimeout, 30); }); + +// The hub's pair, added with its deploy path (release 7). A POST each, with +// the body passed through untouched: the routes judge it. +test("build-hub and deploy-hub are POSTs to their own routes, named in the usage", () => { + const build = parseArgs(["build-hub", "--json", '{"deploy":true}', "--wait"]); + assert.equal(build.method, "POST"); + assert.equal(build.path, "/api/ops/build-hub"); + assert.deepEqual(build.body, { deploy: true }); + assert.equal(build.wait, true); + const deploy = parseArgs(["deploy-hub"]); + assert.equal(deploy.path, "/api/ops/deploy-hub"); + assert.deepEqual(deploy.body, {}); + assert.match(usage(), /Actions:.*build-hub, deploy-hub/); + assert.match(usage(), /build-hub builds the hub into export\/out/); +});