Archilyzer · Source

archilyzer

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

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:
Mcommon/bin/archilyzer.ts | 17+++++++++++++++--
Acommon/bin/publishNow.test.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/publishNow.ts | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}