// EVERY ENVIRONMENT VARIABLE THE REPO READS — the one declared list. // // ENVIRONMENT.md is generated from this (common/bin/env-docs.ts, `--check` in // the common tests), and `envVars.test.ts` holds the list to the code in both // directions: every variable read by common/, editor/, export/, homepage/, // mcp/src and scripts/ (and every `ARCHILYZER_*` name in docker/, the // Dockerfiles and the compose files) is declared here, and every entry here is // still NAMED somewhere outside this file — the code, docker/, a Dockerfile, a // compose file or a package.json script. A variable added without an entry, or // whose last mention is deleted while its entry stays, fails the build. (The // second check is a mention, not a proven read: a name left only in a comment // passes it.) umtool's own knobs are NOT here — its song and report scripts read // dozens, documented in umtool/docs, and fold into the core in one-core Phase 5. // // THE AUDIENCES, which are the point of the table: // paths — the ONE override surface for where things live and which binary // runs: every one is read by getPaths() (lib/paths.ts) and nowhere // else. The test checks both directions. // runtime — the other knobs, secrets and tokens a running process reads. // port — generated from lib/ports.mjs, never listed twice. // internal — set BY the pipeline for a process it spawns. Listed so a reader // knows what it is; nobody sets it by hand. // docker — the container's ARCHILYZER_* set, read by docker/*.sh, the // compose files and Caddy — a different process from the apps, // documented in RUNNING_IN_DOCKER.md. // test — read only by a test harness, a fake binary or a test-mode branch. // // Pure data (no imports but the port table), so a doc generator and a doctor can // both read it without loading anything else. import { PORTS } from "./ports.mjs"; export type EnvAudience = "paths" | "runtime" | "port" | "internal" | "docker" | "test"; export type EnvVarDecl = { name: string; audience: EnvAudience; // What it does, one or two sentences. doc: string; // What an unset variable means, in words (a value, "off", "—"). default: string; // Where it is read — a file, or a short list of them. readBy: string; }; const paths = (name: string, def: string, doc: string): EnvVarDecl => ({ name, audience: "paths", doc, default: def, readBy: "common/lib/paths.ts (getPaths)", }); const DECLARED: EnvVarDecl[] = [ // ── paths: getPaths() ────────────────────────────────────────────────── paths("TRANSCRIPTS_DIR", "`/transcripts`", "The corpus: channels, sites, the LMDB index, job logs, the saved-video store. umtool reads `/channels` too, when its own `CHANNELS_DIR` is unset."), paths("SAVED_VIDEOS_DIR", "`/saved-videos`", "The persisted source-video store, when it should live on another disk."), paths("SITES_DIR", "`/sites`", "Per-site config (`/site.json`, every key in [SITE.md](SITE.md)) and the homepage's `_homepage/`."), paths("SETTINGS_FILE", "`/settings.json`", "The settings file (every key in [SETTINGS.md](SETTINGS.md))."), paths("EXPORT_PUBLIC_DIR", "`/export/public`", "The dir the export site serves at `/`, composed one site at a time."), paths("EXPORT_INDEX_DIR", "`.export-index` beside `EXPORT_PUBLIC_DIR`", "The build's staging area (not served): the shared index and per-site aggregates."), paths("EXPORT_BUILDS_DIR", "`.export-builds` beside `EXPORT_PUBLIC_DIR`", "Per-site `out/` bundles from a docker-mode build."), paths("EDITOR_CHANGELOG_FILE", "`/editor/CHANGELOG.md`", "The editor changelog the release cutter reads and rewrites. The e2e server points it at a gitignored copy."), paths("EXPORT_CHANGELOG_FILE", "`/export/CHANGELOG.md`", "The export changelog, likewise."), paths("CHARTS_CONFIG_FILE", "`/chart-templates.json`", "The legacy chart-templates file, read only by a migration."), paths("SEARCH_ALIASES_FILE", "`/search-aliases.json`", "The corpus-wide search-alias dictionary."), paths("CURATED_TAGS_FILE", "`/tags.json`", "Curated per-video tags. Written only through `applyTagAssignments`."), paths("YTDLP_BIN", "`yt-dlp` on PATH", "The downloader. Every fetch goes through it."), paths("FFMPEG_BIN", "`ffmpeg` on PATH", "Audio extraction for transcription and diarization."), paths("FFPROBE_BIN", "`ffprobe` on PATH", "Duration checks (the short-audio guard, windowing)."), paths("WHISPER_BIN", "`whisper-cli` on PATH", "whisper.cpp, one of the three transcription engines (with chough and parakeet.cpp)."), paths("WHISPER_MODEL", "`~/whispercpp/whisper.cpp/models/ggml-base.en.bin`", "whisper.cpp's model, when a worker names none."), paths("PARAKEET_STITCH_BIN", "`/scripts/parakeet-stitch.mjs`", "The parakeet.cpp engine's wrapper (overlapping windows, stitched)."), paths("PARAKEET_CLI", "`parakeet-cli` on PATH", "The parakeet.cpp binary the wrapper drives (the wrapper reads it too)."), paths("PARAKEET_MODEL", "none", "parakeet.cpp's `.gguf`, when a worker names none (the wrapper reads it too)."), paths("DIARIZE_BIN", "`/scripts/diarize.mjs`", "The speaker-diarization wrapper. The e2e suite swaps in a fake here."), paths("RSYNC_BIN", "`rsync` on PATH", "Mirrors the saved-video store to a backup destination."), paths("FINDMNT_BIN", "`findmnt` on PATH", "The read-only volume-identity probe behind storage locations. Optional."), paths("UDISKSCTL_BIN", "`udisksctl` on PATH", "Mounts an attached volume from `/storage`. Optional."), paths("GALLERY_DL_BIN", "`gallery-dl` on PATH", "The X/Twitter post fetcher, for social channels."), paths("ARIA2C_BIN", "`aria2c` on PATH", "Fetches an archive.org file over BitTorrent (the item's torrent, archive.org as web seed), then seeds it for a while. Optional: without it archive.org files are downloaded directly. See `archiveOrg` in [SETTINGS.md](SETTINGS.md)."), paths("OLLAMA_URL", "`http://127.0.0.1:11434`", "The local ollama server, the local digest and attribution engine."), paths("CLAUDE_BIN", "`claude` on PATH", "The `claude` CLI, driving the opt-in metered digest lane."), paths("ARCHILYZER_CONFIG_DIR", "`~/.config/archilyzer`", "The operator's private config dir, outside the repo: the two inputs of `archilyzer source publish` below. Never committed. In Docker it is `/data/config/archilyzer`, in the config volume (docker-compose.yml)."), paths("SOURCE_SCRUB_FILE", "`/source-scrub.txt`", "git-filter-repo `lhs==>rhs` rules applied to file contents AND commit messages when the source mirror is generated (`==>/home/user` is built in and runs first). Every rule's left side is also denied. See [PUBLISH.md](PUBLISH.md)."), paths("SOURCE_DENYLIST_FILE", "`/source-denylist.txt`", "Literals the published source must never contain, one per line (`i:` = any case). One hit anywhere in the mirror, the tree or the tarball refuses the publish."), paths("ARCHILYZER_SOURCE_SCRATCH", "the OS temp dir", "Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`)."), paths("XDG_CACHE_HOME", "`~/.cache`", "The cache root: `source publish` keeps the history pages' render cache in `/archilyzer/source-history/` (about 140 MB; never inside the checkout)."), paths("STAGIT_BIN", "`stagit` on PATH, then `~/.local/bin/stagit`", "stagit, which renders the source's history pages (`/source/git/`: the log and a page per commit with its diff). Optional: without it the source is published without them. See [PUBLISH.md](PUBLISH.md)."), // ── runtime ──────────────────────────────────────────────────────────── { name: "WORKER_TOKEN", audience: "runtime", default: "unset (both surfaces off)", readBy: "common/lib/workerToken.ts, scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts, mcp/src/editorOps.ts", doc: "Bearer token for the remote-worker API and for `/api/ops/*` (`pnpm ops`; the MCP's `fetch_clip`, `enqueue`, `get_job`, `channel_coverage` and `get_transcript`'s editor fallback). Set the same value on both ends." }, { name: "SYNC_HEARTBEAT_SECONDS", audience: "runtime", default: "`settings.syncScheduler.heartbeatSeconds`", readBy: "editor/app/scheduler/heartbeat.ts", doc: "Overrides the editor's in-process sync heartbeat. `0` = no internal timer (tick from cron instead)." }, { name: "SYNC_TICK_URL", audience: "runtime", default: "`http://127.0.0.1:3001/api/scheduler/tick`", readBy: "common/bin/sync-tick.ts", doc: "Where `archilyzer sync tick` (cron's heartbeat) posts." }, { name: "SYNC_TICK_TOKEN", audience: "runtime", default: "unset (no auth)", readBy: "common/bin/sync-tick.ts, editor/app/scheduler/auth.ts", doc: "Bearer token for the tick endpoint; set on both the editor and the cron job." }, { name: "R2_ACCESS_KEY_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts, common/bin/doctor.ts (set or not)", doc: "R2 S3 credentials for uploading oversize archives at deploy time (with `R2_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCOUNT_ID`), needed only when `archiveStorage.bucket` is set. In Docker they come from `.env`. See [PUBLISH.md](PUBLISH.md)." }, { name: "R2_SECRET_ACCESS_KEY", audience: "runtime", default: "—", readBy: "common/publish/build.ts, common/bin/doctor.ts (set or not)", doc: "See `R2_ACCESS_KEY_ID`." }, { name: "CLOUDFLARE_ACCOUNT_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts, wrangler, common/bin/doctor.ts (set or not)", doc: "The Cloudflare account: the R2 endpoint's, and the one wrangler deploys to when the token can see more than one. In Docker it comes from `.env`." }, { name: "CLOUDFLARE_API_TOKEN", audience: "runtime", default: "unset (wrangler's own `wrangler login` config, on a host)", readBy: "wrangler (every deploy), common/bin/doctor.ts, common/lib/pagesDeploy.ts (set or not, never the value)", doc: "The API token every deploy's wrangler authenticates with (Cloudflare Pages: Edit). The way a container deploys — there is no browser for `wrangler login` in one; set it in `.env`." }, { name: "ARCHILYZER_HOST_ID", audience: "runtime", default: "the hostname", readBy: "common/publish/stageLock.ts (the publish lock)", doc: "Which host the publish lock (`/.publish.lock`) names as its holder's: a lock from this host whose pid is dead is stale and taken over; another host's is waited on. docker-compose.yml fixes it for the editor (`archilyzer-editor`), whose hostname is a container id that changes on every recreate." }, { name: "ARCHILYZER_SOURCE_REPO", audience: "runtime", default: "this checkout's git common dir", readBy: "common/publish/source.ts, common/bin/doctor.ts, docker/entrypoint.sh", doc: "The git DIR `archilyzer source publish` mirrors `main` from, when the checkout has none: in Docker, `/data/source.git`, the host's git common dir mounted read-only by docker-compose.source.yml. A value that names nothing refuses the publish." }, { name: "YTDLP_SOURCE_HOST_DIR", audience: "runtime", default: "— (required by the overlay)", readBy: "docker-compose.ytdlp.yml", doc: "Docker: the HOST path of a yt-dlp source checkout (the directory holding `yt_dlp/`), mounted read-only at `/opt/yt-dlp-src` by docker-compose.ytdlp.yml. See [RUNNING_IN_DOCKER.md](RUNNING_IN_DOCKER.md), \"Substituting yt-dlp\"." }, { name: "YTDLP_AUTO_UPDATE", audience: "runtime", default: "off", readBy: "docker/entrypoint.sh, common/bin/doctor.ts", doc: "Docker: `1` runs `yt-dlp -U` on every editor boot — on the image's yt-dlp only; an override (`YTDLP_BIN` naming another) is left alone, with a warning." }, { name: "XDG_CONFIG_HOME", audience: "runtime", default: "`~/.config`", readBy: "common/bin/doctor.ts, common/lib/pagesDeploy.ts", doc: "Where `wrangler login` keeps its config (`/.wrangler/config/default.toml`); the doctor looks for it there, by path, never reading it." }, { name: "YTDLP_SOURCE_DIR", audience: "runtime", default: "`/opt/yt-dlp-src`", readBy: "docker/yt-dlp-from-source.sh", doc: "Docker: where `/usr/local/bin/yt-dlp-from-source` finds the yt-dlp source tree it runs with the image's python." }, { name: "DOCKER_BIN", audience: "runtime", default: "`docker`", readBy: "common/publish/build.ts", doc: "The container engine for docker-mode builds (e.g. `podman`)." }, { name: "DOCKER_BUILD_MEMORY", audience: "runtime", default: "no cap", readBy: "common/publish/build.ts", doc: "Per-container memory cap for a docker-mode build (`--memory`)." }, { name: "DOCKER_BUILD_CPUS", audience: "runtime", default: "no cap", readBy: "common/publish/build.ts", doc: "Per-container CPU cap for a docker-mode build (`--cpus`)." }, { name: "ARCHIVE_CHANNEL_CONCURRENCY", audience: "runtime", default: "`4`", readBy: "common/bin/build-archives.ts", doc: "How many channels' archive zips `build archives` builds at once." }, { name: "HOST", audience: "runtime", default: "every interface", readBy: "export/scripts/serve-out.mjs", doc: "The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine." }, { name: "MAX_ARCHIVE_BYTES", audience: "runtime", default: "the Cloudflare-safe cap", readBy: "common/bin/compose-site.ts", doc: "The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins." }, { name: "EXPORT_NEXT_BIN", audience: "runtime", default: "unset: `pnpm exec next build` in export/", readBy: "common/publish/build.ts (nextBuildStep)", doc: "The `next` a site's or the hub's build runs as ` build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out." }, { name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." }, { name: "CHOUGH_BIN", audience: "runtime", default: "`chough` on PATH", readBy: "common/lib/transcriptionApps.ts", doc: "The chough transcription engine, when a worker names no binary." }, { name: "CHOUGH_MODEL", audience: "runtime", default: "chough's own", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's model field; chough auto-downloads one when unset." }, { name: "CHOUGH_URL", audience: "runtime", default: "local", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's remote-server field." }, { name: "OLLAMA_DIGEST_MODEL", audience: "runtime", default: "`qwen2.5:7b`", readBy: "common/lib/digestApps.ts", doc: "The ollama model the local digest lane asks for when settings name none." }, { name: "CLAUDE_DIGEST_MODEL", audience: "runtime", default: "the CLI's default", readBy: "common/lib/digestApps.ts", doc: "The model the metered digest lane asks `claude` for when settings name none." }, { name: "NITTER_INSTANCES", audience: "runtime", default: "a built-in list", readBy: "common/social/xNitterFetcher.ts", doc: "Comma-separated Nitter instances for the X fallback fetcher, in order of preference." }, { name: "ARCHILYZER_X_BROWSER", audience: "runtime", default: "the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium", readBy: "common/social/xBrowser.ts", doc: "The Chromium-family browser /settings' \"Connect X account\" opens (a path, or a name looked up on PATH), and a forum-thread channel's \"Connect forum session\" too. It is launched without the automation signals, in the X session profile (or the forum host's profile); a value that is not an executable refuses the connect rather than opening another browser." }, { name: "UMTOOL_URL", audience: "runtime", default: "unset (no link; the MCP's `notes` off)", readBy: "editor/app/channels/[slug]/videos/[id]/page.tsx, mcp/src/archivalTools.ts", doc: "umtool's front door; when set, the video page links to it, and the MCP's `notes` tool reads the operator's notes there (e.g. `http://localhost:3050`)." }, { name: "TRANSCRIPT_SITE_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: one published archive to read over HTTP." }, { name: "TRANSCRIPT_HUB_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a hub, federating every archive it lists." }, { name: "TRANSCRIPT_LOCAL_DIR", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a composed public dir on disk." }, { name: "TRANSCRIPT_PLATFORM_LINKS", audience: "runtime", default: "off", readBy: "common/lib/archive/reader-fs.ts", doc: "`1` cites platform watch pages instead of the archive's own pages." }, { name: "AUDIO_CHECK_RESUME_DURING_PROBE", audience: "runtime", default: "the channel's `audioCheck.resumeDuringProbe`", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "`1` or `true` resumes yt-dlp during the audio check's probe, anything else holds it, for a one-off comparison run; unset = the channel's setting." }, { name: "AUDIO_CHECK_BACKOFF_FACTOR", audience: "runtime", default: "the built-in factor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "The audio check's interval backoff factor, in (0, 1], for a one-off run." }, { name: "ARCHILYZER_STATS_ALLOW_DOWNGRADE", audience: "runtime", default: "off", readBy: "common/controller/buildStats.ts", doc: "`1` lets a stats build clear a stats cache that a NEWER build wrote, for a deliberate rollback. Unset, such a build refuses and names both versions." }, { name: "ARCHILYZER_INDEX_ALLOW_HELD", audience: "runtime", default: "off", readBy: "common/controller/buildIndex.ts", doc: "`1` lets a FULL index rebuild (a schema change, or no index yet) proceed while a channel's media cannot be read; that channel stays out of the index until its media is back and the index is built again. Unset, such a build refuses and names each channel." }, { name: "UV_THREADPOOL_SIZE", audience: "runtime", default: "`16` for the editor (`4` is Node's own)", readBy: "Node's libuv (set by editor/package.json `start` and docker/entrypoint.sh)", doc: "Threads in Node's pool for filesystem calls. A call on a stalled drive holds one until the drive answers, so the editor starts with 16. It buys time for calls already in flight and isolates nothing: the storage health probe and its gate keep new calls off a stalled drive." }, { name: "MCP_IO_STATS", audience: "runtime", default: "off", readBy: "common/lib/archive/io-stats.ts", doc: "`1` turns on per-call I/O accounting, for `mcp/bench`." }, { name: "ARCHILYZER_EDITOR_URL", audience: "runtime", default: "`http://localhost:3001`", readBy: "scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts, mcp/src/editorOps.ts, umtool, common/bin/migrate-media-tier.ts", doc: "Which editor `pnpm ops` and the MCP's editor-backed tools (`fetch_clip`, `enqueue`, `get_job`, `channel_coverage`, `get_transcript`'s fallback) talk to, and whose `/api/pulse` `archilyzer storage migrate-tier` asks before it refuses to run beside it." }, { name: "ARCHILYZER_AGENT", audience: "runtime", default: "`cli`", readBy: "scripts/archilyzer-ops.mjs", doc: "Who is asking, recorded as the provenance of a curated-tag write through `pnpm ops`." }, { name: "DIARIZE_ENGINE_KIND", audience: "runtime", default: "`sherpa-onnx`", readBy: "scripts/diarize.mjs", doc: "The diarization engine: `sherpa-onnx` or `sortformer`." }, { name: "DIARIZE_ENGINE_CMD", audience: "runtime", default: "the bundled sherpa script", readBy: "scripts/diarize.mjs", doc: "The engine command the wrapper runs." }, { name: "DIARIZE_PYTHON", audience: "runtime", default: "`python3`", readBy: "scripts/diarize.mjs", doc: "The python for the default engine." }, { name: "DIARIZE_SEG_MODEL", audience: "runtime", default: "— (required)", readBy: "scripts/diarize.mjs", doc: "Segmentation model. The editor passes the settings' value as a flag." }, { name: "DIARIZE_EMB_MODEL", audience: "runtime", default: "— (required)", readBy: "scripts/diarize.mjs", doc: "Speaker-embedding model. The editor passes the settings' value as a flag." }, { name: "DIARIZE_THRESHOLD", audience: "runtime", default: "`0.5`", readBy: "scripts/diarize.mjs", doc: "Clustering threshold." }, { name: "DIARIZE_THREADS", audience: "runtime", default: "`4`", readBy: "scripts/diarize.mjs", doc: "Engine threads." }, { name: "DIARIZE_WINDOW_MINUTES", audience: "runtime", default: "`45`", readBy: "scripts/diarize.mjs", doc: "Window length for long files; `0` never windows." }, { name: "DIARIZE_WINDOW_AFTER_MINUTES", audience: "runtime", default: "`90`", readBy: "scripts/diarize.mjs", doc: "Only files longer than this are windowed." }, { name: "SORTFORMER_BIN", audience: "runtime", default: "— (required for sortformer)", readBy: "scripts/diarize.mjs, scripts/diarize-sortformer.mjs", doc: "The sortformer engine binary." }, { name: "SORTFORMER_MODEL", audience: "runtime", default: "— (required for sortformer)", readBy: "scripts/diarize.mjs, scripts/diarize-sortformer.mjs", doc: "The sortformer `.gguf`." }, { name: "PARAKEET_SEGMENT_SEC", audience: "runtime", default: "`480`", readBy: "scripts/parakeet-stitch.mjs", doc: "parakeet window length, seconds (a worker's chunk size wins)." }, { name: "PARAKEET_OVERLAP_SEC", audience: "runtime", default: "`6`", readBy: "scripts/parakeet-stitch.mjs", doc: "parakeet window overlap, seconds." }, { name: "PARAKEET_DECODER", audience: "runtime", default: "parakeet-cli's", readBy: "scripts/parakeet-stitch.mjs", doc: "`ctc` or `tdt`, passed through to parakeet-cli." }, { name: "PARAKEET_LANG", audience: "runtime", default: "parakeet-cli's", readBy: "scripts/parakeet-stitch.mjs", doc: "A locale, passed through to parakeet-cli." }, { name: "PARAKEET_DEVICE", audience: "runtime", default: "parakeet-cli's", readBy: "scripts/parakeet-stitch.mjs", doc: "Compute device (`cpu`, `CUDA0`, `Vulkan1`, …), exported to parakeet-cli." }, { name: "HEAVY", audience: "runtime", default: "on", readBy: "scripts/queue-lock.mjs", doc: "`0` skips the heavy slot AND the memory floor: the machine-wide one-at-a-time gate that `pnpm heavy -- `, every e2e entry point and the publish stages' `next build` go through." }, { name: "HEAVY_MIN_FREE_MB", audience: "runtime", default: "`6000`", readBy: "scripts/queue-lock.mjs", doc: "The memory floor: a heavy job, once it holds the slot, waits until /proc/meminfo's MemAvailable is at least this many MB. `0` turns the floor off; a machine whose MemTotal is under it runs without waiting." }, { name: "HEAVY_TIMEOUT", audience: "runtime", default: "wait forever", readBy: "scripts/queue-lock.mjs", doc: "Seconds a `pnpm heavy` run waits for the slot, and then for the floor, before giving up (exit 3). An e2e run uses `E2E_QUEUE_TIMEOUT` for both." }, // ── internal: the pipeline sets these for a process it spawns ────────── { name: "SITE_ID", audience: "internal", default: "—", readBy: "common/bin/compose-site.ts, export/app/lib/site.ts", doc: "Which site a compose or an export build is for. The build stage (`archilyzer publish build `) sets it for its children; `compose site`, `build site` and `deploy site` fall back to it when no id is given." }, { name: "INSTANCE_MODE", audience: "internal", default: "a site", readBy: "export/app/lib/mode.ts, common/lib/archive/contract.ts", doc: "`hub` makes the export build the hub. Set by `archilyzer build hub`." }, { name: "BUILD_ARCHIVES", audience: "internal", default: "on", readBy: "common/bin/compose-site.ts, common/bin/build-archives.ts", doc: "`0` skips archive-zip generation for one build (`--skip-archives`)." }, { name: "REPORTS_ALLOW_MISSING_MEDIA", audience: "internal", default: "off", readBy: "common/bin/compose-site.ts", doc: "`1` lets a report citation whose evidence media was not prepared through compose (`--allow-missing-media`): its moment page renders without a clip. Off, compose fails with the list." }, { name: "ARCHIVES_READONLY", audience: "internal", default: "off", readBy: "common/bin/compose-site.ts", doc: "`1` inside a docker-mode build container: materialize archives, never write the shared cache." }, { name: "HOMEPAGE_PUBLIC_DIR", audience: "internal", default: "`/homepage/public`", readBy: "common/bin/compose-homepage.ts, common/publish/source.ts", doc: "Where `compose homepage` and `source publish` write." }, // ── docker: the container's set ──────────────────────────────────────── { name: "ARCHILYZER_TRANSCRIBER", audience: "docker", default: "baked per image target (`whisper-cpp` in `runtime`)", readBy: "docker/entrypoint.sh", doc: "`whisper-cpp` or `parakeet`: which worker the first boot seeds and which model it fetches." }, { name: "ARCHILYZER_FETCH_MODEL", audience: "docker", default: "per transcriber", readBy: "docker/entrypoint.sh", doc: "Which model the first boot downloads; `none` skips it." }, { name: "ARCHILYZER_MODELS_DIR", audience: "docker", default: "`/data/models`", readBy: "docker/entrypoint.sh", doc: "Where models live in the container." }, { name: "ARCHILYZER_BUILDS_DIR", audience: "docker", default: "`/data/builds`", readBy: "docker/entrypoint.sh", doc: "Where the container keeps built sites." }, { name: "ARCHILYZER_SITE_OUT", audience: "docker", default: "`/data/builds/site`", readBy: "docker/entrypoint.sh, docker/publish-site.sh, common/publish/deployStage.ts", doc: "The built export site the `site` service serves." }, { name: "ARCHILYZER_HOMEPAGE_OUT", audience: "docker", default: "`/data/builds/homepage`", readBy: "docker/entrypoint.sh, common/publish/deployStage.ts", doc: "The locally deployed homepage (`publish homepage --deploy --to local`). The `homepage` service serves it when it is non-empty, else the image's baked build." }, { name: "ARCHILYZER_IMAGE_YTDLP", audience: "docker", default: "baked: `/usr/local/bin/yt-dlp`", readBy: "docker/entrypoint.sh, common/bin/doctor.ts", doc: "The yt-dlp the image ships. `YTDLP_BIN` naming anything else is an OVERRIDE: the boot's `yt-dlp:` line and `archilyzer doctor` say so, and `YTDLP_AUTO_UPDATE` leaves it alone." }, { name: "ARCHILYZER_COMMIT", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback)", doc: "The commit the image was built from — the publish stamps' `commit` where there is no .git. `ARCHILYZER_COMMIT=$(git rev-parse HEAD) docker compose build`." }, { name: "ARCHILYZER_BRANCH", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback)", doc: "The branch the image was built from — the stamps' `branch`, which a production deploy checks." }, { name: "ARCHILYZER_SOURCE_HOST_DIR", audience: "docker", default: "`./.git`", readBy: "docker-compose.source.yml", doc: "The HOST's git common dir docker-compose.source.yml mounts at `/data/source.git`. In a git worktree, the primary checkout's `.git`." }, { name: "ARCHILYZER_IDLE_BOOT", audience: "docker", default: "off", readBy: "common/lib/idleBoot.ts (the editor)", doc: "`1` boots the editor without arming the heartbeat or any auto-queue runner." }, { name: "ARCHILYZER_AUTH_MODE", audience: "docker", default: "`basic`", readBy: "docker/guard-exposure.sh, docker/caddy-start.sh", doc: "`basic`, `forward` or `none` — the only escape hatch from the exposure guard." }, { name: "ARCHILYZER_AUTH_USER", audience: "docker", default: "`archilyzer`", readBy: "docker/Caddyfile", doc: "Basic-auth user." }, { name: "ARCHILYZER_AUTH_HASH", audience: "docker", default: "—", readBy: "docker/Caddyfile, docker/guard-exposure.sh", doc: "Basic-auth bcrypt hash (`caddy hash-password`)." }, { name: "ARCHILYZER_AUTH_IMPORT", audience: "docker", default: "derived from the mode", readBy: "docker/Caddyfile", doc: "Set by docker/caddy-start.sh from the mode: which auth snippet the private sites import." }, { name: "ARCHILYZER_FORWARD_AUTH_UPSTREAM", audience: "docker", default: "—", readBy: "docker/Caddyfile", doc: "Forward-auth server (Authelia, tinyauth, …), `host:port`." }, { name: "ARCHILYZER_FORWARD_AUTH_URI", audience: "docker", default: "`/api/auth/caddy`", readBy: "docker/Caddyfile", doc: "The forward-auth server's verify path." }, { name: "ARCHILYZER_TAG", audience: "docker", default: "`local`", readBy: "docker-compose*.yml", doc: "The image tag the compose files build and run." }, // ── test: harnesses, fakes and test-mode branches ────────────────────── { name: "E2E_TEST_ROUTES", audience: "test", default: "off", readBy: "editor/app/api/test/_guard.ts, editor/instrumentation.ts", doc: "`1` opens the editor's `/api/test/*` routes and marks a test server at boot. Set by `editor/playwright.config.ts` on its test server, and by nothing else." }, { name: "E2E_MODE", audience: "test", default: "`start`", readBy: "editor/playwright.config.ts, umtool/playwright.config.ts, scripts/e2e-stamp.mjs", doc: "`start`: the editor and umtool suites run against `next start` from a build `scripts/e2e-stamp.mjs` vouches for, rebuilt through the heavy slot when the tree moved. `dev`: against `next dev`, for iterating on one spec. The export and homepage suites always run `next dev` (static exports) and say so when asked for `start`." }, { name: "E2E_NEXT_DIST_DIR", audience: "test", default: "`.next`", readBy: "editor/next.config.ts", doc: "The editor's build directory for the e2e suite's start mode (`.next/e2e`), so a test build never replaces the `.next` a running editor serves from. Set by `scripts/e2e-stamp.mjs` for its build and by `editor/playwright.config.ts` for its test server." }, { name: "E2E_BUILD_CHECKED", audience: "test", default: "—", readBy: "editor/playwright.config.ts, umtool/playwright.config.ts", doc: "Set by a suite's config once it has checked the start-mode build's stamp, so a worker's second load of the config does not check again." }, { name: "E2E_QUEUE", audience: "test", default: "on", readBy: "scripts/queue-lock.mjs", doc: "`0` skips the machine-global e2e queue (the port check still runs)." }, { name: "E2E_PORT_CHECK", audience: "test", default: "on", readBy: "scripts/queue-lock.mjs", doc: "`0` skips the pre-run check that the suite's ports are free." }, { name: "E2E_QUEUE_TIMEOUT", audience: "test", default: "wait forever", readBy: "scripts/queue-lock.mjs", doc: "Seconds to wait for the queue before giving up." }, { name: "E2E_PORT_GRACE_MS", audience: "test", default: "`3000`", readBy: "scripts/queue-lock.mjs", doc: "How long the port check waits for a just-freed port." }, { name: "E2E_QUEUE_LOCK_FILE", audience: "test", default: "one per machine", readBy: "scripts/queue-lock.mjs", doc: "The queue's lock file; the queue's own tests point it elsewhere." }, { name: "QUEUE_LOCK_HELD", audience: "test", default: "—", readBy: "scripts/queue-lock.mjs", doc: "Set by the queue for the command it runs, so a nested wrapper passes through." }, { name: "HEAVY_HELD", audience: "test", default: "—", readBy: "scripts/queue-lock.mjs", doc: "Set by the heavy slot for the command it runs, so a heavy command inside it (a `pnpm heavy -- pnpm e2e`, a build stage under an e2e suite's editor) passes through." }, { name: "HEAVY_LOCK_FILE", audience: "test", default: "one per machine", readBy: "scripts/queue-lock.mjs", doc: "The heavy slot's lock file; the gate's own tests point it elsewhere." }, { name: "HEAVY_MEMINFO_FILE", audience: "test", default: "`/proc/meminfo`", readBy: "scripts/queue-lock.mjs", doc: "Where the memory floor reads MemAvailable; the gate's tests hand it a fake." }, { name: "HEAVY_POLL_MS", audience: "test", default: "`5000`", readBy: "scripts/queue-lock.mjs", doc: "How often a run waiting for the memory floor re-reads it." }, { name: "PLAYWRIGHT_BASE_URL", audience: "test", default: "`http://localhost:`", readBy: "editor/playwright.config.ts, editor/e2e/baseUrl.ts", doc: "The editor test server's URL; the worktree injector sets it." }, { name: "E2E_AUDIO_CHECK_INTERVAL_MS", audience: "test", default: "the real cadence", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Shrinks the mid-download audio check so the e2e suite sees it fire." }, { name: "E2E_AUDIO_CHECK_SIZE_GATE", audience: "test", default: "the real gate", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the size gate." }, { name: "E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS", audience: "test", default: "the real floor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the interval floor." }, { name: "E2E_AUDIO_CHECK_RECOVER_STEP_MS", audience: "test", default: "the real step", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery step." }, { name: "E2E_AUDIO_CHECK_RECOVER_AFTER", audience: "test", default: "the real count", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery count." }, { name: "E2E_BACKOFF_BASE_MS", audience: "test", default: "`60000` (the real base)", readBy: "common/jobs/platformBackoff.ts", doc: "The first rate-limit cooldown, which every doubling starts from; the cap and the hold arithmetic keep the real constants. The editor's e2e server sets 20 s, so pacing.spec watches one lapse." }, { name: "E2E_CLIP_WINDOW_GAP_MS", audience: "test", default: "the real gap (30–45 s, more for Rumble)", readBy: "common/controller/fetchWindows.ts", doc: "The pause between two clip-window fetches in one batch. The editor's e2e server sets 2 s, so fetch-window.spec sees the one it owes." }, { name: "E2E_AUDIO_CHECK_DEBUG_PAUSE_MS", audience: "test", default: "off", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "A debugging pause inside the audio check." }, { name: "E2E_FAKE_YTDLP_AUDIO_CHECK_MODE", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: which audio-check scenario to act out." }, { name: "E2E_FAKE_YTDLP_CHUNK_DELAY_MS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: delay between written chunks." }, { name: "E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: start corrupting after this chunk." }, { name: "E2E_FAKE_YTDLP_CORRUPT_RUNS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many runs corrupt." }, { name: "E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: corrupt deterministically." }, { name: "E2E_FAKE_YTDLP_RECOVER_ON_RESUME", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: a resumed run recovers." }, { name: "E2E_FAKE_YTDLP_TOTAL_CHUNKS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many chunks a download has." }, { name: "E2E_FAKE_WRANGLER_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-wrangler.mjs", doc: "Fake wrangler: fail as Cloudflare refusing the API token (`Authentication error [code: 10000]`)." }, { name: "E2E_LIVE_CHECK", audience: "test", default: "on", readBy: "common/publish/liveCheck.ts", doc: "`skip`: a deploy's live check reads nothing and records `skipped`. Set for the editor's test server, whose fake wrangler deploys nothing." }, { name: "E2E_FAKE_GALLERY_DL_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-gallery-dl.mjs", doc: "Fake gallery-dl: fail as an auth error." }, { name: "E2E_FIXTURE_MAX_LIFETIME_MS", audience: "test", default: "the watchdog's", readBy: "editor/e2e/fixtures/bin/_watchdog.mjs", doc: "How long a fake binary may live before its watchdog kills it." }, { name: "E2E_OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." }, { name: "E2E_RACK_SHOTS", audience: "test", default: "off (spec skipped)", readBy: "editor/e2e/channels-rack-audit.spec.ts", doc: "Runs the `/channels` rack screenshot audit." }, { name: "E2E_TWO_ORIGIN_REBUILD", audience: "test", default: "off", readBy: "export/e2e-2origin/globalSetup.ts", doc: "`1` rebuilds the two-origin suite's cached hub bundle." }, { name: "E2E_REPORT_SITE_REUSE", audience: "test", default: "off (build again)", readBy: "export/playwright.report.config.ts", doc: "`1` serves the cited report-site suite's existing stage build (`.e2e-report-site/out`) instead of staging and building the fixture site again — for iterating on the specs alone; it does not see a change to the app." }, { name: "E2E_SHARDS", audience: "test", default: "min(max(2, cpus/2), 8)", readBy: "scripts/run-sharded-e2e.mjs", doc: "How many containers `pnpm e2e:sharded` splits the editor suite across (`--shards N` wins)." }, { name: "E2E_RETRIES", audience: "test", default: "`0`", readBy: "scripts/run-sharded-e2e.mjs", doc: "Retries per shard (`--retries N` wins); 0 keeps a sharded run comparable to a serial one." }, { name: "E2E_IMAGE", audience: "test", default: "`yt-dlp-transcript-browser-e2e`", readBy: "scripts/run-sharded-e2e.mjs", doc: "The sharded e2e run's image tag." }, { name: "E2E_SKIP_BUILD", audience: "test", default: "off", readBy: "scripts/run-sharded-e2e.mjs", doc: "`1` reuses the sharded e2e image instead of rebuilding it (`--no-build`)." }, { name: "E2E_SOURCE_PUBLIC_DIR", audience: "test", default: "unset (the page reads `homepage/public`)", readBy: "homepage/app/lib/source.ts", doc: "The fixture publish the homepage's e2e dev server reads the `/source/` page from while it holds a manifest; set by `homepage/playwright.config.ts`, written and removed by `homepage/e2e/source-history.spec.ts`, ignored by a production build." }, { name: "E2E_EXPECT_SOURCE", audience: "test", default: "off (both states pass)", readBy: "homepage/e2e/source.spec.ts", doc: "`1` makes the homepage suite's `/source/` specs fail on the empty state; a gate that ran `archilyzer source publish` first sets it." }, { name: "E2E_HOMEPAGE_SUMMARY_FILE", audience: "test", default: "`homepage/public/homepage-summary.json`", readBy: "homepage/app/lib/summary.ts", doc: "The synthetic summary the homepage's e2e dev server reads; set by `homepage/playwright.config.ts`, ignored by a production build." }, ]; // The port rows come from the port table, so a port is declared once. const PORT_ROWS: EnvVarDecl[] = Object.entries(PORTS).map(([name, decl]) => ({ name, audience: "port", doc: `${decl.what[0].toUpperCase()}${decl.what.slice(1)}. A worktree adds its offset (\`pnpm wt list\`).`, default: `\`${decl.base}\``, readBy: "common/lib/ports.mjs", })); export const ENV_VARS: readonly EnvVarDecl[] = [...DECLARED, ...PORT_ROWS]; export const ENV_AUDIENCES: ReadonlyArray<{ id: EnvAudience; title: string; intro: string }> = [ { id: "paths", title: "Paths and binaries", intro: "The one override surface for where things live and which binary runs. Every one is read by `getPaths()` (`common/lib/paths.ts`) and nowhere else; nothing hardcodes a location." }, { id: "runtime", title: "Runtime", intro: "Tokens, credentials and knobs a running process reads. Most configuration is not here but in `settings.json` ([SETTINGS.md](SETTINGS.md))." }, { id: "port", title: "Ports", intro: "Every local server's default port, from `common/lib/ports.mjs`. The primary checkout uses these; worktree N adds N × 100 (`pnpm wt list`)." }, { id: "internal", title: "Set by the pipeline", intro: "The publish pipeline sets these for a process it spawns. Listed so a reader knows what they are; nobody sets them by hand." }, { id: "docker", title: "Docker", intro: "The container's own set, read by `docker/*.sh`, the compose files and Caddy — not by the apps' code (except `ARCHILYZER_IDLE_BOOT`, and the image's facts `archilyzer doctor` and the publish stamps read). See [RUNNING_IN_DOCKER.md](RUNNING_IN_DOCKER.md)." }, { id: "test", title: "Tests only", intro: "Read only by a test harness, a fake binary or a test-mode branch. Never set one on a real instance." }, ]; // The two build facts the runtime image bakes (Dockerfile build args → ENV), by // name: the publish stamps' `commit`/`branch` where there is no .git to ask. export const IMAGE_COMMIT_ENV = "ARCHILYZER_COMMIT"; export const IMAGE_BRANCH_ENV = "ARCHILYZER_BRANCH"; // Those facts, or null each — an image built without the args bakes them empty. export function imageBuildFacts(env: NodeJS.ProcessEnv = process.env): { commit: string | null; branch: string | null } { const v = (k: string) => env[k]?.trim() || null; return { commit: v(IMAGE_COMMIT_ENV), branch: v(IMAGE_BRANCH_ENV) }; } export function envVar(name: string): EnvVarDecl | undefined { return ENV_VARS.find((v) => v.name === name); } // ENVIRONMENT.md — one table per audience. export function renderEnvironmentMarkdown(): string { const cell = (s: string) => s.replace(/\|/g, "\\|"); const out: string[] = [ "# Environment variables", "", "", "", "Every environment variable the repo's code reads, by who it is for. The list is code (`common/lib/envVars.ts`), and a test fails when the code reads a variable the list does not declare, or the list declares one that nothing outside the list names any more. umtool's own knobs are documented in [umtool/docs](umtool/docs/README.md).", "", "Regenerate this file with `pnpm archilyzer docs env`. `pnpm archilyzer doctor` prints which of the paths overrides are set on this machine.", "", ]; for (const a of ENV_AUDIENCES) { const rows = ENV_VARS.filter((v) => v.audience === a.id); out.push(`## ${a.title}`, "", a.intro, "", "| Variable | Default | What it does | Read by |", "|---|---|---|---|"); for (const v of rows) { out.push(`| \`${v.name}\` | ${cell(v.default)} | ${cell(v.doc)} | ${cell(v.readBy)} |`); } out.push(""); } return out.join("\n"); }