commit 8d6afff1b19e317844012eeffb8dd917fa27099a
parent b568edb817b2a4c2fa658dd624cb0786cbb5dda8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:48:33 -0400
common: the port defaults and the tool probe exist once
`common/lib/ports.mjs` is the one table of local-server ports (name, base,
what it is for) with the worktree offset step, `portsForOffset` and
`portFor`. `scripts/worktree.mjs` imports it instead of its own copy that
"mirrored" three other places, and gains the three e2e ports it never
offset (HUB_PORT, ORIGIN_B_PORT, HUB_A_PORT). `sync tick`'s default URL
reads EDITOR_PORT from it. The places that cannot import a module (a
package.json script's `${NAME:-N}`, the `--ports NAME:N` list handed to
queue-lock) and the e2e configs are held to it by `ports.test.ts`, which
reads them as text.
`common/lib/toolProbe.mjs` is umtool's binary probe (run the version flag,
ENOENT = absent, the ImageMagick fallback) lifted out so `archilyzer doctor`
can ask the same question; `umtool/lib/tools.mjs` keeps its table and calls
it. `probeTools()` output is byte-identical before and after (checked).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 338 insertions(+), 76 deletions(-)
diff --git a/common/bin/sync-tick.ts b/common/bin/sync-tick.ts
@@ -7,7 +7,7 @@
//
// Env:
// SYNC_TICK_URL full URL of the tick endpoint. Default targets the editor's
-// real dev/start port (3001):
+// real dev/start port (EDITOR_PORT in lib/ports.mjs, 3001):
// http://127.0.0.1:3001/api/scheduler/tick
// SYNC_TICK_TOKEN optional bearer token. When set, it must match the token in
// the editor server's environment or the request is rejected.
@@ -16,8 +16,10 @@
// the output); a normal tick prints a one-line summary for the cron log.
import { runIfEntryPoint } from "./_cli";
+import { PORT_BASES } from "../lib/ports.mjs";
-const DEFAULT_URL = "http://127.0.0.1:3001/api/scheduler/tick";
+// The primary's editor, never a worktree's: cron runs this from the primary.
+const DEFAULT_URL = `http://127.0.0.1:${PORT_BASES.EDITOR_PORT}/api/scheduler/tick`;
// The exit code: 1 on an HTTP error (cron mails it), 0 otherwise. A network
// failure throws.
diff --git a/common/lib/ports.mjs b/common/lib/ports.mjs
@@ -0,0 +1,96 @@
+// THE PORT DEFAULTS — the one copy.
+//
+// Every local server this repo starts has a default port, and a worktree adds
+// `index * OFFSET_STEP` to all of them (scripts/worktree.mjs, WORKTREES.md), so
+// the primary checkout keeps the numbers below and worktree #3 gets 33xx.
+//
+// Plain JS with JSDoc, like ytdlp/platformArgs.mjs, because the first reader is
+// scripts/worktree.mjs, which runs under bare `node`. TS callers (the CLI's
+// doctor, the playwright configs) import it as it is.
+//
+// WHO READS THE NUMBER, AND WHO CANNOT. `scripts/worktree.mjs` (the injector)
+// and `archilyzer doctor` import this table. Two kinds of place cannot, and
+// both are held to it by `ports.test.ts`, which reads them as text and fails
+// when one disagrees:
+// - a package.json script's shell default, `next dev --port ${EDITOR_PORT:-3001}`.
+// A script line is a shell command; it has no import. It keeps the default so
+// `pnpm --filter editor start` works with no wrapper at all, which is what a
+// primary checkout's operator types.
+// - the `--ports NAME:N` list a package's `e2e` script hands queue-lock.mjs.
+// Same reason, and the machine-global queue's argument syntax is shared with
+// checkouts on older code, so it does not change shape here.
+// Everything else — the playwright configs and the e2e helpers — reads it from
+// here or from the env the injector set.
+//
+// NOT HERE: the container's ports (docker-compose.yml, docker/entrypoint.sh,
+// docker/Caddyfile). They are the ports inside a container, where there is one
+// checkout and no worktree offset, and Caddy is the only thing that publishes.
+
+/**
+ * @typedef {{ base: number, what: string }} PortDecl
+ */
+
+/** @type {Readonly<Record<string, PortDecl>>} */
+export const PORTS = Object.freeze({
+ EDITOR_PORT: { base: 3001, what: "editor real dev/start" },
+ PORT: { base: 3011, what: "editor test server + Playwright editor baseURL" },
+ EXPORT_PORT: { base: 3010, what: "export server launched by the editor e2e" },
+ EXPORT_DEV_PORT: { base: 3000, what: "export real dev" },
+ EXPORT_E2E_PORT: { base: 3020, what: "export's own Playwright suite" },
+ OLLAMA_STUB_PORT: { base: 11435, what: "digest-lane stub server in the editor e2e suite" },
+ HOMEPAGE_DEV_PORT: { base: 3030, what: "homepage real dev" },
+ HOMEPAGE_PORT: { base: 3031, what: "homepage static `serve out` (start:homepage)" },
+ HOMEPAGE_E2E_PORT: { base: 3040, what: "homepage's own Playwright suite" },
+ HUB_PORT: { base: 3041, what: "export's hub Playwright suite (e2e:hub)" },
+ UMTOOL_PORT: { base: 3050, what: "umtool real dev/start" },
+ UMTOOL_E2E_PORT: { base: 3051, what: "umtool's own Playwright suite" },
+ EDITOR_STUB_PORT: { base: 3052, what: "stub editor the umtool e2e suite fetches clips from" },
+ ORIGIN_B_PORT: { base: 4610, what: "export's two-origin suite: the member site (e2e:2origin)" },
+ HUB_A_PORT: { base: 4611, what: "export's two-origin suite: the hub (e2e:2origin)" },
+});
+
+/** Base ports (offset 0 == the primary checkout), name -> number. */
+/** @type {Readonly<Record<string, number>>} */
+export const PORT_BASES = Object.freeze(
+ Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, v.base])),
+);
+
+/** A worktree's block is `index * OFFSET_STEP` above the bases. */
+export const OFFSET_STEP = 100;
+
+/**
+ * The port env map for a given offset, plus PLAYWRIGHT_BASE_URL (the editor
+ * test server's URL). Does not consult process.env.
+ * @param {number} offset
+ * @returns {Record<string, string>}
+ */
+export function portsForOffset(offset) {
+ /** @type {Record<string, string>} */
+ const env = {};
+ for (const [key, base] of Object.entries(PORT_BASES)) {
+ env[key] = String(base + offset);
+ }
+ env.PLAYWRIGHT_BASE_URL = `http://localhost:${PORT_BASES.PORT + offset}`;
+ return env;
+}
+
+/**
+ * The port a process should use: the env's value when it is set (the injector
+ * or the operator put it there), else the base. Throws for a name this table
+ * does not declare and for a value that is not a port, so a typo cannot
+ * quietly mean "undefined" or NaN.
+ * @param {string} name
+ * @param {Record<string, string | undefined>} [env]
+ * @returns {number}
+ */
+export function portFor(name, env = process.env) {
+ const decl = PORTS[name];
+ if (!decl) throw new Error(`ports.mjs: no port named ${name}`);
+ const raw = env[name];
+ if (raw != null && raw.trim() !== "") {
+ const n = Number(raw);
+ if (Number.isInteger(n) && n > 0 && n < 65536) return n;
+ throw new Error(`ports.mjs: ${name}=${raw} is not a port`);
+ }
+ return decl.base;
+}
diff --git a/common/lib/ports.test.ts b/common/lib/ports.test.ts
@@ -0,0 +1,133 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ OFFSET_STEP,
+ PORTS,
+ PORT_BASES,
+ portFor,
+ portsForOffset,
+} from "./ports.mjs";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// ports.mjs is the one copy of the port defaults. The places that CANNOT import
+// it (a package.json script's `${NAME:-N}`, the `--ports NAME:N` list handed to
+// queue-lock) and the ones that have not been pointed at it yet still spell a
+// number; this reads them as text and fails the moment one disagrees with the
+// table, or names a port the table does not declare.
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+const PACKAGES = ["", "common", "editor", "export", "homepage", "mcp", "umtool", "umtool/report-to-video"];
+
+function scripts(dir: string): Record<string, string> {
+ const file = path.join(ROOT, dir, "package.json");
+ return (JSON.parse(readFileSync(file, "utf8")).scripts ?? {}) as Record<string, string>;
+}
+
+// The playwright configs and every e2e helper: where a `process.env.X ?? N`
+// fallback can live. Walked, not listed, so a new helper is covered.
+function e2eSources(): string[] {
+ const out: string[] = [];
+ const walk = (dir: string) => {
+ let names: string[];
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return;
+ }
+ for (const n of names) {
+ if (n === "node_modules" || n.startsWith(".")) continue;
+ const p = path.join(dir, n);
+ if (statSync(p).isDirectory()) walk(p);
+ else if (/\.(ts|mts|mjs|js)$/.test(n)) out.push(p);
+ }
+ };
+ for (const pkg of ["editor", "export", "homepage", "umtool"]) {
+ for (const n of readdirSync(path.join(ROOT, pkg))) {
+ if (/^playwright.*\.config\.ts$/.test(n)) out.push(path.join(ROOT, pkg, n));
+ }
+ for (const d of ["e2e", "e2e-2origin"]) walk(path.join(ROOT, pkg, d));
+ }
+ return out;
+}
+
+type Found = { where: string; name: string; port: number };
+
+function found(): Found[] {
+ const hits: Found[] = [];
+ for (const pkg of PACKAGES) {
+ for (const [script, line] of Object.entries(scripts(pkg))) {
+ const where = `${pkg || "."}/package.json "${script}"`;
+ for (const m of line.matchAll(/\$\{([A-Z0-9_]+):-(\d+)\}/g)) {
+ hits.push({ where, name: m[1], port: Number(m[2]) });
+ }
+ for (const m of line.matchAll(/--ports\s+(\S+)/g)) {
+ for (const entry of m[1].split(",")) {
+ const [name, port] = entry.split(":");
+ hits.push({ where: `${where} --ports`, name, port: Number(port) });
+ }
+ }
+ }
+ }
+ for (const file of e2eSources()) {
+ const text = readFileSync(file, "utf8");
+ const where = path.relative(ROOT, file);
+ // A chain (`process.env.EXPORT_E2E_PORT ?? process.env.PORT ?? 3020`) is the
+ // FIRST name's default; the later names are fallbacks for a bare run.
+ for (const m of text.matchAll(
+ /process\.env\.([A-Z0-9_]*PORT)(?:\s*\?\?\s*process\.env\.[A-Z0-9_]+)*\s*\?\?\s*(\d+)/g,
+ )) {
+ hits.push({ where, name: m[1], port: Number(m[2]) });
+ }
+ for (const m of text.matchAll(/PLAYWRIGHT_BASE_URL\s*\?\?\s*["'`]http:\/\/localhost:(\d+)/g)) {
+ hits.push({ where, name: "PORT", port: Number(m[1]) });
+ }
+ }
+ return hits;
+}
+
+test("every spelled port default agrees with ports.mjs", () => {
+ const hits = found();
+ // The scan is not vacuous: the editor's dev script and its queue-lock list
+ // are both spelled today, and must be found.
+ assert.ok(hits.some((h) => h.where.startsWith("editor/package.json") && h.name === "EDITOR_PORT"));
+ assert.ok(hits.some((h) => h.where.endsWith("--ports") && h.name === "PORT"));
+ const wrong = hits
+ .filter((h) => PORT_BASES[h.name] !== h.port)
+ .map((h) =>
+ h.name in PORT_BASES
+ ? `${h.where}: ${h.name} defaults to ${h.port}, ports.mjs says ${PORT_BASES[h.name]}`
+ : `${h.where}: ${h.name} is not declared in common/lib/ports.mjs`,
+ );
+ assert.deepEqual(wrong, []);
+});
+
+test("no two ports share a base, and a worktree block never overlaps the next", () => {
+ const bases = Object.values(PORT_BASES);
+ assert.equal(new Set(bases).size, bases.length);
+ // The 30xx family must fit inside one OFFSET_STEP, or worktree #1's block
+ // would reach into worktree #2's.
+ const family = bases.filter((b) => b >= 3000 && b < 4000);
+ assert.ok(Math.max(...family) - Math.min(...family) < OFFSET_STEP);
+});
+
+test("portsForOffset adds the offset to every port and names the editor test URL", () => {
+ const env = portsForOffset(300);
+ for (const [name, base] of Object.entries(PORT_BASES)) {
+ assert.equal(env[name], String(base + 300));
+ }
+ assert.equal(env.PLAYWRIGHT_BASE_URL, `http://localhost:${PORT_BASES.PORT + 300}`);
+ assert.equal(Object.keys(env).length, Object.keys(PORTS).length + 1);
+});
+
+test("portFor: the env wins, else the base; an unknown name or a non-port throws", () => {
+ assert.equal(portFor("PORT", {}), 3011);
+ assert.equal(portFor("PORT", { PORT: "3611" }), 3611);
+ assert.equal(portFor("PORT", { PORT: " " }), 3011);
+ assert.throws(() => portFor("PROT", {}), /no port named PROT/);
+ assert.throws(() => portFor("PORT", { PORT: "30x1" }), /is not a port/);
+});
diff --git a/common/lib/toolProbe.mjs b/common/lib/toolProbe.mjs
@@ -0,0 +1,94 @@
+// Is this external tool on this machine, and which version? — the one probe.
+//
+// Plain ESM (no app imports) so umtool's `lib/tools.mjs`, which `umtool doctor`
+// runs under bare node, and `archilyzer doctor` probe a binary the SAME way: a
+// doctor that asked a different question than its twin would be worse than no
+// doctor. Each caller keeps its own TABLE of tools (what it needs, and why);
+// only the asking lives here.
+//
+// READ-ONLY by construction: a probe runs the tool's version flag (or stats a
+// file) and nothing else. Never call it from a page render — it forks.
+import { execFile } from "node:child_process";
+import { stat } from "node:fs/promises";
+import { promisify } from "node:util";
+
+const execFileP = promisify(execFile);
+
+/**
+ * @typedef {{ id: string, bin?: string, file?: string, args?: string[], fallback?: string, neededBy: string[], required?: boolean }} ToolSpec
+ * @typedef {{ id: string, bin: string, present: boolean, version: string | null, error: string | null, neededBy: string[], required: boolean }} ToolReport
+ */
+
+const firstLine = (/** @type {unknown} */ s) =>
+ String(s ?? "").split("\n").find((l) => l.trim()) ?? "";
+
+/**
+ * `ffmpeg version 7.1.1 …` -> `7.1.1`; `2025.08.11` -> itself.
+ * @param {unknown} text
+ * @returns {string | null}
+ */
+export function versionOf(text) {
+ const line = firstLine(text);
+ const m = line.match(/(\d+\.\d+(?:\.\d+)*(?:[-_.][A-Za-z0-9]+)*)/);
+ return m ? m[1] : line.slice(0, 60) || null;
+}
+
+/**
+ * Probe one tool: run `bin args` (5 s cap), or stat `file` when the spec names a
+ * file instead of a binary. ENOENT is "not on this machine"; any other exit
+ * means the binary RAN — an unusual version flag, say — which is presence,
+ * honestly reported. A `fallback` (ImageMagick 6's `convert` for 7's `magick`)
+ * is reported as absent-with-a-reason, because the pipeline calls `bin`.
+ * `env` is the environment the tool runs under (its PATH decides which binary a
+ * bare name finds); default: this process's.
+ * @param {ToolSpec} t
+ * @param {{ env?: NodeJS.ProcessEnv }} [opts]
+ * @returns {Promise<ToolReport>}
+ */
+export async function probeTool(t, opts = {}) {
+ const base = {
+ id: t.id,
+ bin: t.file ?? t.bin ?? "",
+ neededBy: t.neededBy,
+ required: !!t.required,
+ };
+ if (t.file) {
+ const ok = await stat(t.file).then(
+ (s) => s.isFile(),
+ () => false,
+ );
+ return { ...base, present: ok, version: null, error: ok ? null : `${t.file} is missing` };
+ }
+ const args = t.args ?? ["--version"];
+ /** @param {string} bin */
+ const run = async (bin) => {
+ try {
+ const { stdout, stderr } = await execFileP(bin, args, {
+ timeout: 5000,
+ maxBuffer: 1 << 20,
+ ...(opts.env ? { env: opts.env } : {}),
+ });
+ return { present: true, version: versionOf(stdout || stderr), error: null };
+ } catch (/** @type {any} */ err) {
+ if (err?.code === "ENOENT") return { present: false, version: null, error: `${bin}: not found` };
+ const said = versionOf(err?.stdout || err?.stderr);
+ return {
+ present: true,
+ version: said || null,
+ error: said ? null : `${bin} exited ${err?.code ?? "?"} on ${args.join(" ")}`,
+ };
+ }
+ };
+ let r = await run(/** @type {string} */ (t.bin));
+ if (!r.present && t.fallback) {
+ const f = await run(t.fallback);
+ if (f.present) {
+ r = {
+ present: false,
+ version: f.version,
+ error: `only \`${t.fallback}\` is installed (ImageMagick 6); the pipeline calls \`${t.bin}\``,
+ };
+ }
+ }
+ return { ...base, ...r };
+}
diff --git a/common/package.json b/common/package.json
@@ -32,6 +32,8 @@
"./components/virtualizer": "./components/virtualizer.ts",
"./components/*": "./components/*.tsx",
"./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs",
+ "./lib/ports.mjs": "./lib/ports.mjs",
+ "./lib/toolProbe.mjs": "./lib/toolProbe.mjs",
"./ytdlp/platformArgs.mjs": "./ytdlp/platformArgs.mjs",
"./lib/*": "./lib/*.ts",
"./controller/*": "./controller/*.ts",
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
@@ -10,24 +10,10 @@ import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
+import { OFFSET_STEP, portsForOffset } from "../common/lib/ports.mjs";
-// Base ports (offset 0 == main worktree). Mirrors the hardcoded defaults in
-// editor/export package.json scripts and the Playwright configs.
-const PORT_BASES = {
- EDITOR_PORT: 3001, // editor real dev/start
- PORT: 3011, // editor test server + Playwright editor baseURL
- EXPORT_PORT: 3010, // export server launched by editor e2e
- EXPORT_DEV_PORT: 3000, // export real dev
- EXPORT_E2E_PORT: 3020, // export's own Playwright suite
- OLLAMA_STUB_PORT: 11435, // digest-lane stub server in the editor e2e suite
- HOMEPAGE_DEV_PORT: 3030, // homepage (hub) real dev
- HOMEPAGE_PORT: 3031, // homepage static `serve out` (start:homepage)
- HOMEPAGE_E2E_PORT: 3040, // homepage's own Playwright suite
- UMTOOL_PORT: 3050, // um-clip triage tool real dev
- UMTOOL_E2E_PORT: 3051, // umtool's own Playwright suite
- EDITOR_STUB_PORT: 3052, // stub editor the umtool e2e suite fetches clips from
-};
-const OFFSET_STEP = 100;
+// The port table — names, bases and the offset step — is common/lib/ports.mjs,
+// the one copy. This file only decides WHICH offset a checkout gets.
function git(args, opts = {}) {
// With stdio:"inherit" execFileSync returns null (output not captured).
@@ -87,16 +73,6 @@ function offsetForIndex(index) {
return index * OFFSET_STEP;
}
-// Compute the port env map for a given offset. Does not consult process.env.
-function portsForOffset(offset) {
- const env = {};
- for (const [key, base] of Object.entries(PORT_BASES)) {
- env[key] = String(base + offset);
- }
- env.PLAYWRIGHT_BASE_URL = `http://localhost:${PORT_BASES.PORT + offset}`;
- return env;
-}
-
// Index of the worktree containing `dir` (default: cwd) in the worktree list.
function indexForDir(dir = process.cwd()) {
const trees = listWorktrees();
diff --git a/umtool/lib/tools.mjs b/umtool/lib/tools.mjs
@@ -1,21 +1,20 @@
// Which external tools are on this machine, and which pipeline needs which.
//
// Plain ESM with no app imports so `umtool doctor` runs from a terminal, and so
-// the app's /api/doctor and the CLI cannot disagree about what was probed.
+// the app's /api/doctor and the CLI cannot disagree about what was probed. The
+// PROBE itself (run the version flag, read the version, ENOENT = absent) is
+// common/lib/toolProbe.mjs, shared with `archilyzer doctor`, which reports this
+// table too; only the table lives here.
//
// THE ONE RULE: this is never run during a render, and never from a page
// render. Probing seven binaries is ~100 ms of fork/exec, which is nothing once
// and a tax on every request if it leaks into a page. So the app keeps a cached
// report with a TTL and the dashboard shows the cache or "not checked" plus a
// button; only the button and the CLI probe.
-import { execFile } from "node:child_process";
-import { stat } from "node:fs/promises";
import path from "node:path";
-import { promisify } from "node:util";
+import { probeTool } from "yt-dlp-transcript-common/lib/toolProbe.mjs";
import { SONG_SCRATCH } from "./paths.mjs";
-const execFileP = promisify(execFile);
-
/** Mirrors lib/faces.ts, which re-exports these so the two cannot drift. */
export const facedetPython = () =>
process.env.FACEDET_PYTHON ?? path.join(SONG_SCRATCH, "facedet", "bin", "python");
@@ -43,52 +42,12 @@ export const TOOLS = () => [
{ id: "facecrop.py", file: facecropPy(), neededBy: ["faces"], required: false },
];
-const firstLine = (s) => String(s ?? "").split("\n").find((l) => l.trim()) ?? "";
-/** `ffmpeg version 7.1.1 …` -> `7.1.1`; `2025.08.11` -> itself. */
-const versionOf = (text) => {
- const line = firstLine(text);
- const m = line.match(/(\d+\.\d+(?:\.\d+)*(?:[-_.][A-Za-z0-9]+)*)/);
- return m ? m[1] : line.slice(0, 60) || null;
-};
-
-async function probeOne(t) {
- const base = { id: t.id, bin: t.file ?? t.bin, neededBy: t.neededBy, required: !!t.required };
- if (t.file) {
- const ok = await stat(t.file).then((s) => s.isFile(), () => false);
- return { ...base, present: ok, version: null, error: ok ? null : `${t.file} is missing` };
- }
- const run = async (bin) => {
- try {
- const { stdout, stderr } = await execFileP(bin, t.args, { timeout: 5000, maxBuffer: 1 << 20 });
- return { present: true, version: versionOf(stdout || stderr), error: null };
- } catch (err) {
- // ENOENT is "not on this machine". Any other exit means the binary RAN --
- // an unusual version flag, say -- which is presence, honestly reported.
- if (err?.code === "ENOENT") return { present: false, version: null, error: `${bin}: not found` };
- const said = versionOf(err?.stdout || err?.stderr);
- return { present: true, version: said || null, error: said ? null : `${bin} exited ${err?.code ?? "?"} on ${t.args.join(" ")}` };
- }
- };
- let r = await run(t.bin);
- if (!r.present && t.fallback) {
- const f = await run(t.fallback);
- if (f.present) {
- r = {
- present: false,
- version: f.version,
- error: `only \`${t.fallback}\` is installed (ImageMagick 6); the pipeline calls \`${t.bin}\``,
- };
- }
- }
- return { ...base, ...r };
-}
-
/**
* Run every tool's version flag. ~100 ms in total, in parallel.
* @returns {Promise<{ checkedAt: number, tools: Array<{id:string,bin:string,present:boolean,version:string|null,error:string|null,neededBy:string[],required:boolean}>, ok: boolean }>}
*/
export async function probeTools() {
- const tools = await Promise.all(TOOLS().map(probeOne));
+ const tools = await Promise.all(TOOLS().map((t) => probeTool(t)));
return {
checkedAt: Date.now(),
tools,