// `archilyzer publish …` and `archilyzer stage …` (release 18): the publish // stages from the command line. The table rows are in archilyzer.ts; this is // what they run. // // publish index update-index (as a child: its heap cap) // publish build [--runner local|docker|auto] [--force] [--skip-archives] // [--allow-missing-media] [--out ] // publish deploy [--preview ] [--to local] [--force] // publish hub [--deploy] [--preview ] [--force] // publish homepage [--deploy] [--preview ] [--to local] [--force] // stage [flags] the child the editor spawns // // Every stage runs under the publish lock (publish/stageLock.ts), so a CLI run // beside the editor waits for the editor's stage — and the editor's for it. // `publish index` runs the SAME child the editor spawns (stageCommand: the // index and stats builds want its 8 GB heap); the rest run in this process. // `publish status` and `publish now` are release 18 S3's (publishState.ts). import { killChildTreesNow, runChildIntoLog, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; import { siteDeployProblem } from "../lib/builtExport"; import { listSites, listSiteIds } from "../lib/site"; import { newStampId } from "../publish/stamps"; import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun"; import { parseStageArgs, type StageRequest } from "../publish/stages"; import type { CommandContext } from "./_cli"; import path from "node:path"; import { copyFile, link, lstat, mkdir, readdir, readlink, rm, symlink } from "node:fs/promises"; type Out = { log: (s: string) => void; error: (s: string) => void }; function terminalLog(line: string): void { process.stdout.write(line.endsWith("\n") ? line : `${line}\n`); } /** A run id for the stages one CLI command runs (the editor's are its own). */ export function cliRunId(): string { return `cli-${newStampId()}`; } // Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does. A // SECOND one does not wait for the unwind: it kills the detached process // groups this process started (`next build`'s, wrangler's) and exits 130 — // never leaving an orphan builder writing export/out. Idempotent: one handler. function interrupted(): AbortSignal { const ac = new AbortController(); const onSignal = () => { if (!ac.signal.aborted) { ac.abort(); return; } killChildTreesNow("SIGKILL"); process.exit(STAGE_EXIT.cancelled); }; process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); return ac.signal; } // --- stage (the child) -------------------------------------------------------- export async function stageRow(ctx: CommandContext, out: Out = console): Promise { const req = parseStageArgs(ctx.positionals, ctx.flags); if ("error" in req) { out.error(req.error); return STAGE_EXIT.usage; } return stageMain(req); } // --- publish index ------------------------------------------------------------ /** * Run update-index as the editor does: a child with the index heap cap. A * Ctrl-C reaches the child from the terminal (it unwinds and exits 130); a * SIGTERM to this process is passed on to it. */ export async function publishIndex(opts: { paths?: Paths; runId?: string } = {}): Promise { const paths = opts.paths ?? getPaths(); const req: StageRequest = { kind: "update-index", target: "_index", runId: opts.runId ?? cliRunId() }; const cmd = stageCommand(paths, req); const ac = new AbortController(); const onInt = () => {}; const onTerm = () => ac.abort(); process.on("SIGINT", onInt); process.on("SIGTERM", onTerm); try { return await runChildIntoLog(terminalLog, ac.signal, cmd); } finally { process.off("SIGINT", onInt); process.off("SIGTERM", onTerm); } } // --- publish build / deploy / hub / homepage ---------------------------------- function knownSite(id: string, name: string, out: Out, paths?: Paths): boolean { const known = listSiteIds(paths); if (known.includes(id)) return true; out.error(`${name}: no site "${id}" (configured: ${known.join(", ") || "none"})`); return false; } async function run(req: StageRequest, signal: AbortSignal, paths?: Paths): Promise { setKillChildTrees(true); return (await runStage(req, { paths, signal })).code; } export type BuildArgs = { target: string; // a site id or "all" runner?: "local" | "docker" | "auto"; force?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean; // A private build served elsewhere (release 19 A8): after the build (or the // no-op of a fresh one), the bundle is laid out in this directory. out?: string; runId?: string; paths?: Paths; signal?: AbortSignal; }; export async function publishBuild(a: BuildArgs, out: Out = console): Promise { const all = a.target === "all"; if (all && a.out !== undefined) { out.error("publish build: --out lays out ONE site's bundle — give a site id, not all"); return STAGE_EXIT.usage; } if (!all && !knownSite(a.target, "publish build", out, a.paths)) return STAGE_EXIT.usage; if (a.out !== undefined) { // Refused before the build, so a typo'd destination costs no build. const paths = a.paths ?? getPaths(); const { bundleDir } = await import("../publish/build"); const { localDestProblem } = await import("../publish/deployStage"); const problem = await localDestProblem(path.resolve(a.out), bundleDir(paths, a.target), paths); if (problem) { out.error(`publish build: --out ${problem.replace(/a local deploy empties/g, "--out empties")}`); return STAGE_EXIT.usage; } const code = await publishBuild({ ...a, out: undefined }, out); if (code !== 0) return code; return layOutBundle(paths, a.target, path.resolve(a.out), out); } const signal = a.signal ?? interrupted(); let runner: "local" | "docker" = "local"; if (a.runner === "docker" || a.runner === "auto") { if (!all) { out.error("publish build: --runner docker builds every site in containers — publish build all --runner docker"); return STAGE_EXIT.usage; } if (a.runner === "docker") runner = "docker"; else { const { dockerAvailable } = await import("../publish/build"); runner = (await dockerAvailable(signal)) ? "docker" : "local"; if (runner === "local") out.log("[notice] No container engine available — building sites serially on the host."); } } return run( { kind: "build-site", target: all ? "_all" : a.target, runId: a.runId ?? cliRunId(), ...(all ? { runner } : {}), ...(a.force ? { force: true } : {}), ...(a.skipArchives ? { skipArchives: true } : {}), ...(a.allowMissingMedia ? { allowMissingMedia: true } : {}), }, signal, a.paths, ); } // --- publish build --out -------------------------------------------------------- // THE SITE'S BUNDLE, LAID OUT IN `dest` for a server that is not Pages (a // private build served elsewhere). Cheap: every file is a HARD LINK into the // bundle when the two share a filesystem — a build never edits a bundle in // place, it installs a new one (publish/build.ts installBundle), so the links // keep the files of the build they were made from — else a copy. `dest` is // emptied first (its contents, never the directory: it may be a mount) and // guarded as a local deploy's destination is (localDestProblem). Not a deploy: // nothing is recorded in deployed.json. export async function layOutBundle(paths: Paths, target: string, dest: string, out: Out = console): Promise { const { bundleDir } = await import("../publish/build"); const { readBuiltStamp } = await import("../publish/stamps"); const src = bundleDir(paths, target); const built = await readBuiltStamp(paths, target); if (!built) { out.error(`publish build: no build of ${target} in ${src} — nothing to lay out`); return STAGE_EXIT.precondition; } await mkdir(dest, { recursive: true }); for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true }); const counts = { linked: 0, copied: 0, links: 0 }; const walk = async (from: string, to: string): Promise => { for (const e of await readdir(from, { withFileTypes: true })) { const s = path.join(from, e.name); const d = path.join(to, e.name); if (e.isDirectory()) { await mkdir(d, { recursive: true }); await walk(s, d); } else if (e.isSymbolicLink()) { await symlink(await readlink(s), d); counts.links++; } else { try { await link(s, d); counts.linked++; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "EXDEV" && code !== "EPERM" && code !== "EMLINK") throw err; await copyFile(s, d); counts.copied++; } } } }; const st = await lstat(src).catch(() => null); if (!st?.isDirectory()) { out.error(`publish build: the bundle ${src} is missing`); return STAGE_EXIT.precondition; } await walk(src, dest); out.log( `[out] ${target}: build ${built.stampId} laid out in ${dest} — ${counts.linked} hard-linked, ` + `${counts.copied} copied${counts.links ? `, ${counts.links} symlinks` : ""} (not a deploy: nothing recorded).`, ); return 0; } export type DeployArgs = { target: string; // a site id or "all" preview?: string; to?: "pages" | "local"; force?: boolean; runId?: string; paths?: Paths; signal?: AbortSignal; }; export async function publishDeploy(a: DeployArgs, out: Out = console): Promise { const all = a.target === "all"; if (!all && !knownSite(a.target, "publish deploy", out, a.paths)) return STAGE_EXIT.usage; const signal = a.signal ?? interrupted(); const runId = a.runId ?? cliRunId(); // `all` passes over — quietly, one line — only the sites that are never // deployable by their configuration: a private site, and (to Pages) a site // with no Pages project. Every other refusal (never built, a bundle // problem, a build not of main, a build this run has not made) is a // FAILURE: the rest are still tried, and the run exits 1. const ids: string[] = []; for (const site of all ? listSites(a.paths) : []) { const never = siteDeployProblem(site) ?? (a.to === "local" || site.cloudflareProject?.trim() ? null : "no Cloudflare Pages project"); if (never) out.log(`[publish] ${site.siteId}: skipped — ${never}`); else ids.push(site.siteId); } if (!all) ids.push(a.target); let worst = 0; for (const id of ids) { if (signal.aborted) return STAGE_EXIT.cancelled; const code = await run( { kind: "deploy-site", target: id, runId, ...(a.preview ? { preview: a.preview } : {}), ...(a.to ? { to: a.to } : {}), ...(a.force ? { force: true } : {}), }, signal, a.paths, ); if (code === STAGE_EXIT.cancelled) return code; if (code !== 0) worst = all ? STAGE_EXIT.failed : worst || code; } return worst; } export async function publishHub( a: { deploy?: boolean; // Ship the hub's bundle as it is: no build (`deploy hub`, the old row). deployOnly?: boolean; preview?: string; force?: boolean; runId?: string; paths?: Paths; signal?: AbortSignal; }, out: Out = console, ): Promise { if (a.deploy && a.deployOnly) { out.error("publish hub: --deploy builds then deploys, --deploy-only ships the bundle as built — one of them"); return STAGE_EXIT.usage; } const deploy = a.deploy || a.deployOnly; if (a.preview && !deploy) { out.error("publish hub: --preview is a deploy — add --deploy"); return STAGE_EXIT.usage; } const signal = a.signal ?? interrupted(); const runId = a.runId ?? cliRunId(); if (!a.deployOnly) { const built = await run({ kind: "build-hub", target: "_hub", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths); if (built !== 0 || !deploy) return built; } return run( { kind: "deploy-hub", target: "_hub", runId, ...(a.preview ? { preview: a.preview } : {}), // --deploy-only --force re-ships the bundle as built; with a build in the // run, --force was the build's. ...(a.deployOnly && a.force ? { force: true } : {}), }, signal, a.paths, ); } export async function publishHomepage( a: { deploy?: boolean; // Ship homepage/out as it is: no build (`deploy homepage`, the old row). deployOnly?: boolean; preview?: string; to?: "pages" | "local"; force?: boolean; runId?: string; paths?: Paths; signal?: AbortSignal; }, out: Out = console, ): Promise { if (a.deploy && a.deployOnly) { out.error("publish homepage: --deploy builds then deploys, --deploy-only ships it as built — one of them"); return STAGE_EXIT.usage; } const deploy = a.deploy || a.deployOnly; if ((a.preview || a.to) && !deploy) { out.error("publish homepage: --preview and --to are a deploy's — add --deploy"); return STAGE_EXIT.usage; } const signal = a.signal ?? interrupted(); const runId = a.runId ?? cliRunId(); if (!a.deployOnly) { const built = await run( { kind: "build-homepage", target: "_homepage", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths, ); if (built !== 0 || !deploy) return built; } return run( { kind: "deploy-homepage", target: "_homepage", runId, ...(a.preview ? { preview: a.preview } : {}), ...(a.to ? { to: a.to } : {}), ...(a.deployOnly && a.force ? { force: true } : {}), }, signal, a.paths, ); } // --- the old rows, as printed aliases ------------------------------------------- export const ALIASES = { buildSite: (id: string, nodata: boolean) => `${nodata ? "" : "archilyzer publish index && "}archilyzer publish build ${id} --force`, buildAll: "archilyzer publish index && archilyzer publish build all --runner auto", deploySite: (id: string, preview?: string) => `archilyzer publish deploy ${id}${preview ? ` --preview ${preview}` : ""}`, deployHub: (preview?: string) => `archilyzer publish hub --deploy-only${preview ? ` --preview ${preview}` : ""}`, deployHomepage: (preview?: string) => `archilyzer publish homepage --deploy-only${preview ? ` --preview ${preview}` : ""}`, } as const; /** `build site [--nodata]` = `publish index` (skipped by --nodata) + `publish build --force`. */ export async function buildSiteAlias( a: { siteId: string; nodata: boolean; skipArchives: boolean; allowMissingMedia: boolean }, out: Out = console, ): Promise { if (!knownSite(a.siteId, "build site", out)) return STAGE_EXIT.usage; out.log(`[alias] build site is now: ${ALIASES.buildSite(a.siteId, a.nodata)}`); const runId = cliRunId(); if (!a.nodata) { const code = await publishIndex({ runId }); if (code !== 0) return code; } return publishBuild( { target: a.siteId, force: true, skipArchives: a.skipArchives, allowMissingMedia: a.allowMissingMedia, runId, }, out, ); } /** `build all` = `publish index` + `publish build all --runner auto`. */ export async function buildAllAlias(a: { skipArchives: boolean }, out: Out = console): Promise { out.log(`[alias] build all is now: ${ALIASES.buildAll}`); const runId = cliRunId(); const code = await publishIndex({ runId }); if (code !== 0) return code; return publishBuild({ target: "all", runner: "auto", skipArchives: a.skipArchives, runId }, out); } /** `deploy site ` = `publish deploy `. */ export async function deploySiteAlias( a: { siteId: string; preview?: string }, out: Out = console, ): Promise { out.log(`[alias] deploy site is now: ${ALIASES.deploySite(a.siteId, a.preview)}`); return publishDeploy({ target: a.siteId, preview: a.preview }, out); } /** `deploy hub` = `publish hub --deploy-only` (the hub's bundle, as built). */ export async function deployHubAlias(a: { preview?: string }, out: Out = console): Promise { out.log(`[alias] deploy hub is now: ${ALIASES.deployHub(a.preview)}`); return publishHub({ deployOnly: true, preview: a.preview }, out); } /** `deploy homepage` = `publish homepage --deploy-only` (homepage/out, as built). */ export async function deployHomepageAlias(a: { preview?: string }, out: Out = console): Promise { out.log(`[alias] deploy homepage is now: ${ALIASES.deployHomepage(a.preview)}`); return publishHomepage({ deployOnly: true, preview: a.preview }, out); }