Archilyzer · Source

archilyzer

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

commit 3c062e11f1d145bd044ab38ede181290627e6a13
parent bf30586f88acfc421419cc23e6925e331acfc77f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 11:06:42 -0400

common: bins export main() and run only as the entry point; archilyzer table

build-index, build-stats, build-chart-templates, build-archives, compose-site,
compose-hub, compose-homepage, sync-tick, settings-example and
file-schemas-docs each export an async main(opts) and auto-run only when node
was started on them (runIfEntryPoint, the worktree.mjs idiom realpath'd), so
tsx bin/<name>.ts works as before. compose-site takes the site id as an
argument and falls back to SITE_ID. bin/archilyzer.ts is the command table:
index, compose site|hub|homepage, sync tick, settings example. Root
build:index and sync:tick call it.

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

Diffstat:
Mcommon/bin/_cli.ts | 41+++++++++++++++++++++++++++++++++++++++++
Acommon/bin/archilyzer.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/build-archives.ts | 12+++++-------
Mcommon/bin/build-chart-templates.ts | 23++++++++++++++---------
Mcommon/bin/build-index.ts | 14+++++++++-----
Mcommon/bin/build-stats.ts | 14+++++++++-----
Mcommon/bin/compose-homepage.ts | 12+++++-------
Mcommon/bin/compose-hub.ts | 12+++++-------
Mcommon/bin/compose-site.ts | 21+++++++++++----------
Mcommon/bin/file-schemas-docs.ts | 15++++++---------
Mcommon/bin/settings-example.ts | 17+++++++----------
Mcommon/bin/sync-tick.ts | 31++++++++++++++++++++++---------
Mpackage.json | 4++--
13 files changed, 200 insertions(+), 80 deletions(-)

diff --git a/common/bin/_cli.ts b/common/bin/_cli.ts @@ -4,6 +4,8 @@ // one. The table itself is `archilyzer.ts`; this file holds nothing that knows // what any command does, so it is unit-tested without importing one. +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { parseArgv, type FlagValue } from "./_parseFlags"; // A flag a command accepts. "boolean" never takes a value (`--nodata`), @@ -134,3 +136,42 @@ export async function runCli( } return hit.command.run({ positionals: hit.rest, flags, env }); } + +/** + * True when the module at `metaUrl` is the script node was started with — the + * `scripts/worktree.mjs` idiom, so a bin can export `main` for the CLI to call + * AND keep working as `tsx bin/<name>.ts`. Both sides are realpath'd, because + * `import.meta.url` is always the real file and argv[1] may reach it through a + * workspace symlink. + */ +export function isEntryPoint(metaUrl: string): boolean { + const argv1 = process.argv[1]; + if (!argv1) return false; + try { + return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argv1); + } catch { + return false; + } +} + +/** + * Run `main` when `metaUrl` is the entry point, the way every bin always has: + * a thrown error prints and exits 1; a returned number becomes the exit code + * without cutting the process short (a bin that returns nothing exits 0 when + * its work drains, exactly as before it exported anything). + */ +export function runIfEntryPoint( + metaUrl: string, + main: () => Promise<number | void>, +): void { + if (!isEntryPoint(metaUrl)) return; + main().then( + (code) => { + if (typeof code === "number") process.exitCode = code; + }, + (err: unknown) => { + console.error(err); + process.exit(1); + }, + ); +} diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env tsx +// `archilyzer` — the one command line over the publish layer and the bins. +// +// pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts <command> +// (or `tsx ../common/bin/archilyzer.ts …` from export/, as its scripts do) +// +// Every row imports its implementation LAZILY, so `archilyzer sync tick` never +// loads the AWS SDK and `archilyzer settings example` never opens LMDB. The +// machinery (parser, lookup, usage) is `_cli.ts`; this file is only the table. +// +// Not here yet (one-core Phase 4 slice 3): `doctor`, `run <operation>`, `mcp`. + +import type { Command } from "./_cli"; +import { runCli, runIfEntryPoint } from "./_cli"; + +export const COMMANDS: Command[] = [ + { + path: ["index"], + usage: "rebuild the LMDB transcript index", + run: async () => { + await (await import("./build-index")).main(); + return 0; + }, + }, + { + path: ["compose", "site"], + usage: "<id> compose one site's export/public (default: SITE_ID)", + maxPositionals: 1, + run: async ({ positionals }) => { + await (await import("./compose-site")).main({ siteId: positionals[0] }); + return 0; + }, + }, + { + path: ["compose", "hub"], + usage: "compose the hub's export/public (hub-sites.json, corpus.json, …)", + run: async () => { + await (await import("./compose-hub")).main(); + return 0; + }, + }, + { + path: ["compose", "homepage"], + usage: "compose homepage/public (whole-pool stats + landing summary)", + run: async () => { + await (await import("./compose-homepage")).main(); + return 0; + }, + }, + { + path: ["sync", "tick"], + usage: "POST one scheduler tick to the editor (SYNC_TICK_URL, SYNC_TICK_TOKEN)", + run: async () => (await import("./sync-tick")).tick(), + }, + { + path: ["settings", "example"], + usage: "[--check] write settings.json.example + SETTINGS.md from the schema", + flags: { check: "boolean" }, + run: async ({ flags }) => + (await import("./settings-example")).main({ check: flags.check === true }), + }, +]; + +runIfEntryPoint(import.meta.url, () => runCli(COMMANDS, process.argv.slice(2))); diff --git a/common/bin/build-archives.ts b/common/bin/build-archives.ts @@ -13,7 +13,7 @@ // so an unchanged channel is reused, not re-zipped — this is a no-op on a warm // cache and also speeds the basic (non-docker) build by collapsing N per-site // passes into one union pass. -import { getPaths } from "../lib/paths"; +import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { listSites } from "../lib/site"; import { openChannelSigner } from "../lib/channelSignature"; @@ -22,8 +22,9 @@ import { archiveTranscripts, } from "../controller/archiveTranscripts"; import { archiveLiveChat } from "../controller/archiveLiveChat"; +import { runIfEntryPoint } from "./_cli"; -async function main(): Promise<void> { +export async function main(opts: { paths?: Paths } = {}): Promise<void> { // Honor the same global/per-build opt-outs the per-site compose respects // (archivesEnabled in compose-site.ts). The per-site `archives` flag is applied // below when building the union. @@ -36,7 +37,7 @@ async function main(): Promise<void> { return; } - const paths = getPaths(); + const paths = opts.paths ?? getPaths(); const sites = listSites(paths); // Union of member slugs across sites that ship archives. A channel shared by @@ -85,7 +86,4 @@ async function main(): Promise<void> { console.log("[archives] cache warm complete."); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/build-chart-templates.ts b/common/bin/build-chart-templates.ts @@ -2,19 +2,24 @@ // Bakes each site's editor-authored chart templates into its export staging dir // as chart-templates.json, composed into the site's bundle by build:site. Falls // back to built-in presets when a site has authored no templates file. -import { getPaths } from "../lib/paths"; +import { getPaths, type Paths } from "../lib/paths"; import { listSiteIds } from "../lib/site"; import { syncTemplatesToExport, siteTemplatesStagingPath, } from "../lib/chartsStore"; +import { runIfEntryPoint } from "./_cli"; -const paths = getPaths(); -const siteIds = listSiteIds(paths); -if (siteIds.length === 0) { - console.log("No sites configured; nothing to do."); -} -for (const siteId of siteIds) { - syncTemplatesToExport(paths, siteId); - console.log(`Wrote ${siteTemplatesStagingPath(paths, siteId)}`); +export async function main(opts: { paths?: Paths } = {}): Promise<void> { + const paths = opts.paths ?? getPaths(); + const siteIds = listSiteIds(paths); + if (siteIds.length === 0) { + console.log("No sites configured; nothing to do."); + } + for (const siteId of siteIds) { + syncTemplatesToExport(paths, siteId); + console.log(`Wrote ${siteTemplatesStagingPath(paths, siteId)}`); + } } + +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/build-index.ts b/common/bin/build-index.ts @@ -1,8 +1,12 @@ #!/usr/bin/env tsx -import { getPaths } from "../lib/paths"; +// `archilyzer index` — rebuild the LMDB transcript index. Also runs on its own +// as `tsx bin/build-index.ts` (export's build:index script). +import { getPaths, type Paths } from "../lib/paths"; import { buildIndex } from "../controller/buildIndex"; +import { runIfEntryPoint } from "./_cli"; -buildIndex({ paths: getPaths() }).catch((err) => { - console.error(err); - process.exit(1); -}); +export async function main(opts: { paths?: Paths } = {}): Promise<void> { + await buildIndex({ paths: opts.paths ?? getPaths() }); +} + +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/build-stats.ts b/common/bin/build-stats.ts @@ -1,8 +1,12 @@ #!/usr/bin/env tsx -import { getPaths } from "../lib/paths"; +// Rebuild the stats datasets. Runs as `tsx bin/build-stats.ts` (export's +// build:stats script); `main` is exported for the archilyzer CLI. +import { getPaths, type Paths } from "../lib/paths"; import { buildStats } from "../controller/buildStats"; +import { runIfEntryPoint } from "./_cli"; -buildStats({ paths: getPaths() }).catch((err) => { - console.error(err); - process.exit(1); -}); +export async function main(opts: { paths?: Paths } = {}): Promise<void> { + await buildStats({ paths: opts.paths ?? getPaths() }); +} + +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/compose-homepage.ts b/common/bin/compose-homepage.ts @@ -13,7 +13,7 @@ import path from "node:path"; import { mkdir, readFile } from "node:fs/promises"; -import { getPaths } from "../lib/paths"; +import { getPaths, type Paths } from "../lib/paths"; import { writeJsonAtomic as writeJsonAtomicShared } from "../lib/jsonFile-server"; import { buildStats } from "../controller/buildStats"; import { listSites } from "../lib/site"; @@ -26,6 +26,7 @@ import { buildHomepageSummary, type ChannelSitesMap, } from "../lib/homepageSummary"; +import { runIfEntryPoint } from "./_cli"; // Where the homepage Next.js app serves static assets from. Overridable for e2e // test isolation, mirroring EXPORT_PUBLIC_DIR. @@ -63,8 +64,8 @@ async function readStatsPages(statsDir: string): Promise<VideoStat[]> { return out; } -async function main(): Promise<void> { - const paths = getPaths(); +export async function main(opts: { paths?: Paths } = {}): Promise<void> { + const paths = opts.paths ?? getPaths(); const publicDir = homepagePublicDir(paths.monorepoRoot); const statsDir = path.join(publicDir, "stats"); await mkdir(statsDir, { recursive: true }); @@ -106,7 +107,4 @@ async function main(): Promise<void> { ); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts @@ -13,7 +13,7 @@ import path from "node:path"; import { cp, rm, writeFile, access } from "node:fs/promises"; -import { getPaths } from "../lib/paths"; +import { getPaths, type Paths } from "../lib/paths"; import { listSites, resolveHubUrl } from "../lib/site"; import { getHomepageConfig } from "../lib/homepage"; import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor"; @@ -24,6 +24,7 @@ import { type HubMemberInput, } from "../lib/corpus"; import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers"; +import { runIfEntryPoint } from "./_cli"; async function exists(p: string): Promise<boolean> { try { @@ -34,8 +35,8 @@ async function exists(p: string): Promise<boolean> { } } -async function main(): Promise<void> { - const paths = getPaths(); +export async function main(opts: { paths?: Paths } = {}): Promise<void> { + const paths = opts.paths ?? getPaths(); const publicDir = paths.exportPublicDir; // Built-in pool: every configured site that publishes a public URL. The entry @@ -105,7 +106,4 @@ async function main(): Promise<void> { ); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -17,7 +17,7 @@ import path from "node:path"; import { cp, link, mkdir, rm, readdir, access, readFile, writeFile, stat, rename } from "node:fs/promises"; import { createHash } from "node:crypto"; import type { Dirent } from "node:fs"; -import { getPaths } from "../lib/paths"; +import { getPaths, type Paths } from "../lib/paths"; import { getSite, resolveSocialLinks, resolveHubUrl, type Site } from "../lib/site"; import { getSettings } from "../lib/settings"; import { @@ -60,6 +60,7 @@ import { type ArchiveManifest, type ArchiveManifestEntry, } from "../lib/archiveOptions"; +import { runIfEntryPoint } from "./_cli"; // Emit the public federation contract: /site.json (branding + channels + // freshness) and the CORS _headers file. Emitted for EVERY site regardless of @@ -631,13 +632,16 @@ async function reconcileChannelTree( return next; } -async function main(): Promise<void> { - const siteId = process.env.SITE_ID; +// `archilyzer compose site <id>` passes the id; the bare bin (export's +// compose:site script) reads SITE_ID, as it always has. +export async function main( + opts: { siteId?: string; paths?: Paths } = {}, +): Promise<void> { + const siteId = opts.siteId?.trim() || process.env.SITE_ID; if (!siteId) { - console.error("compose-site: SITE_ID env var is required"); - process.exit(1); + throw new Error("compose-site: a site id is required (argument or SITE_ID env var)"); } - const paths = getPaths(); + const paths = opts.paths ?? getPaths(); const site = getSite(siteId, paths); const memberSlugs = site.channels.map((c) => c.slug); @@ -913,7 +917,4 @@ async function main(): Promise<void> { ); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +runIfEntryPoint(import.meta.url, () => main()); diff --git a/common/bin/file-schemas-docs.ts b/common/bin/file-schemas-docs.ts @@ -20,6 +20,7 @@ import { renderSiteMarkdown, } from "../lib/fileSchemaDocs"; import { parseFlags } from "./_parseFlags"; +import { runIfEntryPoint } from "./_cli"; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); @@ -28,9 +29,9 @@ const FILE_SCHEMA_OUTPUTS: ReadonlyArray<[string, () => string]> = [ ["CHANNEL.md", renderChannelMarkdown], ]; -async function main(): Promise<number> { - const flags = parseFlags(process.argv.slice(2)); - const check = flags.check === "true"; +// `check` writes nothing and returns 1 when a committed file is stale. +export async function main(opts: { check?: boolean } = {}): Promise<number> { + const check = opts.check === true; let stale = 0; for (const [name, render] of FILE_SCHEMA_OUTPUTS) { const file = path.join(REPO, name); @@ -49,10 +50,6 @@ async function main(): Promise<number> { return stale > 0 ? 1 : 0; } -main().then( - (code) => process.exit(code), - (err) => { - console.error(err); - process.exit(1); - }, +runIfEntryPoint(import.meta.url, () => + main({ check: parseFlags(process.argv.slice(2)).check === "true" }), ); diff --git a/common/bin/settings-example.ts b/common/bin/settings-example.ts @@ -7,7 +7,7 @@ // // `--check` writes nothing and exits 1 if either committed file differs from // what the schema generates (the same claim common/lib/settingsDocs.test.ts -// makes). Becomes `archilyzer settings example` in one-core phase 4. +// makes). `archilyzer settings example [--check]` calls main(). // // Reads no settings.json and writes no settings.json: both outputs are // functions of the schema alone. @@ -20,6 +20,7 @@ import { renderSettingsMarkdown, } from "../lib/settingsDocs"; import { parseFlags } from "./_parseFlags"; +import { runIfEntryPoint } from "./_cli"; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); @@ -28,9 +29,9 @@ const OUTPUTS: ReadonlyArray<[string, () => string]> = [ ["SETTINGS.md", renderSettingsMarkdown], ]; -async function main(): Promise<number> { - const flags = parseFlags(process.argv.slice(2)); - const check = flags.check === "true"; +// `check` writes nothing and returns 1 when a committed file is stale. +export async function main(opts: { check?: boolean } = {}): Promise<number> { + const check = opts.check === true; let stale = 0; for (const [name, render] of OUTPUTS) { const file = path.join(REPO, name); @@ -49,10 +50,6 @@ async function main(): Promise<number> { return stale > 0 ? 1 : 0; } -main().then( - (code) => process.exit(code), - (err) => { - console.error(err); - process.exit(1); - }, +runIfEntryPoint(import.meta.url, () => + main({ check: parseFlags(process.argv.slice(2)).check === "true" }), ); diff --git a/common/bin/sync-tick.ts b/common/bin/sync-tick.ts @@ -15,11 +15,17 @@ // Exits non-zero on a network/HTTP error so cron surfaces failures (e.g. mails // the output); a normal tick prints a one-line summary for the cron log. +import { runIfEntryPoint } from "./_cli"; + const DEFAULT_URL = "http://127.0.0.1:3001/api/scheduler/tick"; -async function main(): Promise<void> { - const url = process.env.SYNC_TICK_URL ?? DEFAULT_URL; - const token = process.env.SYNC_TICK_TOKEN; +// The exit code: 1 on an HTTP error (cron mails it), 0 otherwise. A network +// failure throws. +export async function main( + opts: { url?: string; token?: string } = {}, +): Promise<number> { + const url = opts.url ?? process.env.SYNC_TICK_URL ?? DEFAULT_URL; + const token = opts.token ?? process.env.SYNC_TICK_TOKEN; const headers: Record<string, string> = { "content-type": "application/json" }; if (token) headers.authorization = `Bearer ${token}`; @@ -27,8 +33,7 @@ async function main(): Promise<void> { const text = await res.text(); if (!res.ok) { console.error(`[sync-tick] ${res.status} ${res.statusText}: ${text}`); - process.exitCode = 1; - return; + return 1; } try { @@ -47,9 +52,17 @@ async function main(): Promise<void> { } catch { console.log(`[sync-tick] ${text}`); } + return 0; +} + +// Same message as ever for a network failure, which throws out of fetch(). +export async function tick(): Promise<number> { + try { + return await main(); + } catch (err) { + console.error(`[sync-tick] request failed: ${(err as Error).message}`); + return 1; + } } -main().catch((err: unknown) => { - console.error(`[sync-tick] request failed: ${(err as Error).message}`); - process.exit(1); -}); +runIfEntryPoint(import.meta.url, tick); diff --git a/package.json b/package.json @@ -5,8 +5,8 @@ "license": "MIT", "type": "module", "scripts": { - "build:index": "pnpm --filter yt-dlp-transcript-common exec tsx bin/build-index.ts", - "sync:tick": "pnpm --filter yt-dlp-transcript-common exec tsx bin/sync-tick.ts", + "build:index": "pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts index", + "sync:tick": "pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts sync tick", "build:export": "pnpm --filter export run build", "build:homepage": "pnpm --filter homepage run build", "build:homepage:nodata": "pnpm --filter homepage run build:nodata",