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:
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",