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:
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’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’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’s own (for example{" "}
+ <code>archilyzer-hub</code>). The <code>homepage</code> package —
+ Archilyzer’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/);
+});