Archilyzer · Source

archilyzer

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

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:
Mcommon/bin/sync-tick.ts | 6++++--
Acommon/lib/ports.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/ports.test.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/toolProbe.mjs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/package.json | 2++
Mscripts/worktree.mjs | 30+++---------------------------
Mumtool/lib/tools.mjs | 53++++++-----------------------------------------------
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,