commit ba6e09d699a082505048de70293f49d2ed5bfdd2
parent 4af664ce7cbd7495a5f6eb1c25fead860df129c5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 11:27:32 -0400
cli: archilyzer publish status [--json] and publish now
`publish status` prints the publish status (readPublishStatus): the index,
the lane, one row per site / hub / homepage with its policy and its built,
deployed and live chips and what is next, then the plan Publish now would
run. `publish now` runs that plan — never forced — one stage at a time in
this process under the publish lock, as the editor's queue would (the CLI
has no queue): the same bodies, order and on-disk preconditions; the index
update as the same child `publish index` runs. At S1's marked seam.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 217 insertions(+), 2 deletions(-)
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -156,8 +156,21 @@ export const COMMANDS: Command[] = [
});
},
},
- // `publish status` and `publish now` read the publish state view — release 18
- // slice S3 adds their rows here.
+ // `publish status` and `publish now` read the publish state view (release 18
+ // S3; bin/publishNow.ts).
+ {
+ path: ["publish", "status"],
+ usage:
+ "[--json] the publish status: the index, the lane, and per site / hub / homepage its policy and its built, deployed and live chips, then the plan Publish now would run",
+ flags: { json: "boolean" },
+ run: async ({ flags }) => (await import("./publishNow")).publishStatusRow({ json: flags.json === true }),
+ },
+ {
+ path: ["publish", "now"],
+ usage:
+ "run what Publish now runs — the index update when it is stale, then each policy target's build and deploy (site.json publish.auto; settings.json publish.hub / publish.homepage), never forced — one stage at a time IN THIS PROCESS under the publish lock, as the editor's queue would (the CLI has no queue); exit 0 when every stage ran or was a no-op",
+ run: async () => (await import("./publishNow")).publishNow(),
+ },
{
path: ["stage"],
usage:
diff --git a/common/bin/publishNow.test.ts b/common/bin/publishNow.test.ts
@@ -0,0 +1,75 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { COMMANDS } from "./archilyzer";
+import { argumentProblem, booleanFlags, resolveCommand, usage } from "./_cli";
+import { parseArgv } from "./_parseFlags";
+import { defaultPublish } from "../lib/settingsSchema";
+import { buildPublishStatus, type PublishInputs } from "../publish/publishPlan";
+import { statusLines } from "./publishNow";
+
+// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test bin/publishNow.test.ts
+//
+// `publish status [--json]` and `publish now` (release 18 S3): their rows, and
+// the status's lines over a hand-built status. `publish now` itself runs the
+// stage bodies, which the stage tests (publish/stages*.test.ts) cover.
+
+test("publish status takes --json and nothing else; publish now takes nothing", () => {
+ const status = resolveCommand(COMMANDS, ["publish", "status"])?.command;
+ assert.deepEqual(status?.path, ["publish", "status"]);
+ assert.deepEqual(status?.flags, { json: "boolean" });
+ const parsed = parseArgv(["publish", "status", "--json"], booleanFlags(COMMANDS));
+ assert.deepEqual(parsed, { positionals: ["publish", "status"], flags: { json: true } });
+ assert.equal(argumentProblem(status!, parsed.flags, []), null);
+ assert.match(argumentProblem(status!, {}, ["x"])!, /unexpected argument "x"/);
+ const now = resolveCommand(COMMANDS, ["publish", "now"])?.command;
+ assert.deepEqual(now?.path, ["publish", "now"]);
+ assert.match(argumentProblem(now!, { force: true }, [])!, /unknown flag --force/);
+ const u = usage(COMMANDS);
+ assert.match(u, /archilyzer publish status\s+\[--json\] the publish status/);
+ assert.match(u, /archilyzer publish now\s+run what Publish now runs .* IN THIS PROCESS under the publish lock, as the editor's queue would \(the CLI has no queue\)/);
+});
+
+test("the status reads as one row per target, then the plan", () => {
+ const empty = {
+ built: null,
+ deployed: null,
+ bundleProblem: null,
+ deployProblem: null,
+ pagesProblem: null,
+ configChangedAt: null,
+ policy: "off" as const,
+ cloudflareProject: null,
+ url: null,
+ };
+ const i: PublishInputs = {
+ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null },
+ ingestByChannel: {},
+ commit: null,
+ sites: [{ ...empty, siteId: "alpha", title: "Alpha", private: false, listed: true, members: [], policy: "build" }],
+ hub: empty,
+ homepage: { ...empty, mainHead: null },
+ settings: defaultPublish(),
+ jobs: [],
+ ended: [],
+ lane: {
+ known: false,
+ running: false,
+ jobId: null,
+ passRunning: false,
+ lastCheckAt: null,
+ nextCheckAt: null,
+ lastPassAt: null,
+ lastPassSummary: null,
+ lastDecision: null,
+ },
+ };
+ const lines = statusLines(buildPublishStatus(i, 1_900_000_000_000));
+ assert.equal(lines[0], "index no index yet");
+ assert.match(lines[1], /^lane {11}lane off — the lane is off$/);
+ assert.match(lines[3], /^alpha +\| \[build\] +\| built: update the index first \| deployed: not deployed \| live: not checked \| next: build$/);
+ assert.match(lines[4], /^_hub /);
+ assert.match(lines[5], /^_homepage /);
+ assert.equal(lines[7], "Publish now would run:");
+ assert.match(lines[8], /^ update-index _index — no index stamp yet$/);
+ assert.match(lines[9], /^ build-site alpha — built after the index's first update$/);
+});
diff --git a/common/bin/publishNow.ts b/common/bin/publishNow.ts
@@ -0,0 +1,127 @@
+// `archilyzer publish status` and `archilyzer publish now` (release 18 S3).
+//
+// publish status [--json] the publish status every surface reads
+// (publish/publishState.ts readPublishStatus): one
+// row per target with the chips' words, the lane,
+// and the plan Publish now would run
+// publish now run that plan: update-index when stale, then each
+// policy target's build and deploy — never forced
+//
+// THE CLI HAS NO QUEUE. Where the editor's Publish now enqueues the whole plan
+// on its `publish` queue (enqueuePublishRun), `publish now` runs the plan's
+// stages one after another IN THIS PROCESS, each under the publish lock —
+// exactly what the queue would do: the same stage bodies, in the same order,
+// with the same on-disk preconditions (a build carries `indexAfter`, a deploy
+// `builtAfter`), so a stage whose precondition is not met refuses (exit 3) and
+// the rest are still tried. The index update runs as the same child the
+// editor spawns (its heap cap), as `publish index` does.
+
+import { setKillChildTrees, killChildTreesNow } from "../jobs/runChild";
+import { getPaths, type Paths } from "../lib/paths";
+import { planPublishRun, type PublishStatus, type PublishTargetStatus } from "../publish/publishPlan";
+import { readPublishStatus } from "../publish/publishState";
+import { STAGE_EXIT, runStage } from "../publish/stageRun";
+import type { StageRequest } from "../publish/stages";
+import { cliRunId, publishIndex } from "./publish";
+
+type Out = { log: (s: string) => void; error: (s: string) => void };
+
+function pad(s: string, n: number): string {
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
+}
+
+function targetRow(t: PublishTargetStatus): string {
+ const c = t.chips;
+ return [
+ pad(t.target, 14),
+ pad(`[${t.policy}]`, 13),
+ `built: ${c.built.text}`,
+ `deployed: ${c.deployed.text}`,
+ `live: ${c.live.text}`,
+ `next: ${t.next}`,
+ ].join(" | ");
+}
+
+/** The status as lines (exported for the test). */
+export function statusLines(s: PublishStatus): string[] {
+ const out: string[] = [];
+ out.push(`index ${s.index.chip.text}`);
+ out.push(`lane ${s.lane.chip.text} — ${s.lane.reason}`);
+ out.push("");
+ for (const t of [...s.sites, s.hub, s.homepage]) out.push(targetRow(t));
+ out.push("");
+ if (s.plan.steps.length === 0) out.push("Publish now would run nothing.");
+ else {
+ out.push("Publish now would run:");
+ for (const step of s.plan.steps) {
+ const where = step.preview ? ` --preview ${step.preview}` : step.to === "local" ? " --to local" : "";
+ out.push(` ${step.kind} ${step.target}${where} — ${step.reason}`);
+ }
+ }
+ for (const skip of s.plan.skipped) out.push(` (skipped ${skip.kind} ${skip.target}: ${skip.reason})`);
+ return out;
+}
+
+export async function publishStatusRow(
+ a: { json?: boolean; paths?: Paths },
+ out: Out = console,
+): Promise<number> {
+ const status = await readPublishStatus(a.paths ?? getPaths(), { laneKnown: false });
+ if (a.json) out.log(JSON.stringify(status, null, 2));
+ else for (const line of statusLines(status)) out.log(line);
+ return 0;
+}
+
+/**
+ * Run Publish now's plan in this process, one stage at a time, as the
+ * editor's queue would. Exit: 0 when every stage ran or was a no-op, else
+ * the worst stage's code (130 at once on a cancel).
+ */
+export async function publishNow(
+ a: { paths?: Paths; signal?: AbortSignal } = {},
+ out: Out = console,
+): Promise<number> {
+ const paths = a.paths ?? getPaths();
+ const status = await readPublishStatus(paths, { laneKnown: false });
+ const plan = planPublishRun(status, { deploys: "policy" });
+ const runId = cliRunId();
+ for (const skip of plan.skipped) out.log(`[publish] skipped ${skip.kind} ${skip.target}: ${skip.reason}`);
+ if (plan.steps.length === 0) {
+ out.log("[publish] nothing to do: the index and every policy target are current");
+ return 0;
+ }
+ out.log(`[publish] run ${runId}: ${plan.steps.map((s) => `${s.kind} ${s.target}`).join(", ")}`);
+ const ac = new AbortController();
+ const onSignal = () => {
+ if (!ac.signal.aborted) return ac.abort();
+ killChildTreesNow("SIGKILL");
+ process.exit(STAGE_EXIT.cancelled);
+ };
+ const signal = a.signal ?? ac.signal;
+ if (!a.signal) {
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+ }
+ setKillChildTrees(true);
+ let worst = 0;
+ try {
+ for (const step of plan.steps) {
+ if (signal.aborted) return STAGE_EXIT.cancelled;
+ const { reason: _reason, previewUrl: _previewUrl, ...rest } = step;
+ const req: StageRequest = { ...rest, runId };
+ const code =
+ req.kind === "update-index"
+ ? await publishIndex({ paths, runId })
+ : (await runStage(req, { paths, signal })).code;
+ if (code === STAGE_EXIT.cancelled) return code;
+ // A failure (1) outranks a refusal (3) or a usage error (2).
+ if (code !== 0 && worst !== STAGE_EXIT.failed) worst = code;
+ }
+ } finally {
+ if (!a.signal) {
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ }
+ }
+ return worst;
+}