import fs from "node:fs"; import path from "node:path"; import os from "node:os"; // curatedTags.ts is pure (no imports of its own), so this cannot cycle. import { TAGS_FILENAME } from "./curatedTags"; export type Paths = { monorepoRoot: string; transcriptsDir: string; channelsDir: string; // Root of the saved-video store: persisted source-video containers (the // keep-latest persistence rule) are MOVED out of the per-video data dir into // savedVideosDir///, leaving only a small saved-video.json // pointer in the data dir. Defaults under transcriptsDir but is overridable // via SAVED_VIDEOS_DIR so the (large) source videos can live on a separate // disk. A channel may further override the root via ChannelConfig.savedVideosDir. // See common/lib/savedVideo.ts. savedVideosDir: string; // Per-site config lives under sitesDir//site.json (+ chart-templates.json). // See common/lib/site.ts. A "site" is a selection + presentation layer over the // single global channel pool; channel downloads are never duplicated per site. sitesDir: string; // The Archilyzer hub/homepage (the `homepage` SSG package): a single, instance- // level cross-site landing site, NOT a content site. Its config + chart // dashboard live under sitesDir/_homepage. The leading underscore is // intentionally not a valid siteId (SITE_ID_RE), so listSiteIds() ignores it. // See common/lib/homepage.ts. homepageDir: string; homepageConfigFile: string; homepageChartTemplatesFile: string; jobsDir: string; // Scratch area for remote-worker transcription requests: uploaded audio and // the produced transcript.json live under workerScratchDir// and // are cleaned up once the requesting instance pulls the result (or cancels). workerScratchDir: string; // Persistent state for the cron-driven sync scheduler (per-channel backoff + // a rolling run log). Survives restarts, unlike the in-memory job registry. // See common/jobs/syncSchedulerState.ts. schedulerStateFile: string; // Persistent fairness state for the automatic priority-queue runners // (round-robin / weighted-fair cursors + a rolling pick log). Best-effort: // drift across a restart is cosmetic. See common/jobs/autoQueueState.ts. autoQueueStateFile: string; // Persisted "default" worker arrangement: the set of worker ids that should // start enabled on the next server launch (workers not listed start // disabled). Written by the Workers page "Set as default" button; applied by // the worker pool on first use. See common/jobs/workerDefaults.ts. workerDefaultsFile: string; // Saved monitor-widget presets: named /widget query strings the builder and // the in-widget menu both load from. A preset stores the LINK, not a parsed // config, so a preset and a shared link are literally the same artifact. See // common/lib/widgetPresets.ts. widgetPresetsFile: string; // The two CHANGELOG.md files the "cut release" flow reads and rewrites. They // are TRACKED source files, and cutting a release optionally makes a real git // commit — so e2e must be able to point them somewhere disposable. Overridable // via EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE; the editor's dev:test and // start:test scripts set them to editor/test-changelog.md and // editor/test-export-changelog.md (both gitignored) — the second since // POST /api/ops/cut-release made the export changelog reachable from a spec. // Without this the changelog spec rewrites the repo's own changelog and can // leave a stray "Release editor " commit behind. editorChangelogFile: string; exportChangelogFile: string; lmdbPath: string; exportDir: string; // The dir Next.js serves at "/" (also holds checked-in static assets). For // multi-site builds this is composed one site at a time from the staging dirs // below: per-site summaries/subs-manifest/stats/chart-templates plus the // site's subset of the shared transcripts/subs trees. exportPublicDir: string; exportSummariesDir: string; exportTranscriptsDir: string; exportSubsDir: string; // Served per-channel social-post page tree (/posts//{manifest,page-NNNN}.json). // The parallel corpus to transcripts/subs — see common/lib/posts.ts. exportPostsDir: string; // Served per-channel AI-digest page tree // (/digests//{manifest,page-NNNN}.json). The DERIVED corpus: chapters // and topic tags generated from a transcript, composed with human overrides // at index time. Sparse by design — only videos that have been digested // appear in a manifest's slugToPage, which is what lets the viewer hide its // Digest control without a per-video fetch. See common/lib/digests.ts. exportDigestsDir: string; exportStatsDir: string; // Staging area (NOT served) where the shared index + per-site aggregates are // built before composition. Shared per-channel transcript/subs pages are // identical across sites and built once under exportSharedDir; per-site // summaries/subs-manifest/stats land under exportSitesIndexDir//. exportIndexDir: string; exportSharedDir: string; exportSharedTranscriptsDir: string; exportSharedSubsDir: string; exportSharedPostsDir: string; exportSharedDigestsDir: string; exportSitesIndexDir: string; // Per-site extracted static output (`out/`) from an isolated (Docker) build, // keyed exportBuildsDir/. Sibling of .export-index. The wrangler deploy // reads a site's bundle from here in docker mode (in basic mode the deploy // reads export/out directly). Env-overridable so test data stays isolated. exportBuildsDir: string; settingsFile: string; chartsConfigFile: string; // Global (cross-site) search-alias dictionary. Per-site aliases live at // sitesDir//search-aliases.json (see common/lib/site.ts); the two are // merged into each site's shipped /search-aliases.json at compose time. In the // data dir (not monorepoRoot — the legacy chartsConfigFile location there is // migration-only). See common/lib/aliasesStore.ts. globalAliasesFile: string; // Curated per-video tags: the corpus-wide vocabulary AND every assignment // (/tags.json). Per-site presentation overlays live at // sitesDir//tags.json (see common/lib/site.ts). Authoritative, and // real curated data — never hand-edited. See common/lib/curatedTagsStore.ts. globalTagsFile: string; ytdlpBin: string; whisperBin: string; whisperModel: string; ffmpegBin: string; // ffprobe binary, used to measure a downloaded audio file's actual duration // for the download-time short-audio guard (common/ytdlp/ffprobeDuration.ts). // Ships alongside ffmpeg. ffprobeBin: string; // rsync binary used to mirror the saved-video store to a backup destination // (Phase 4 of the video-persistence feature). See // common/controller/backupSavedVideos.ts. rsyncBin: string; // aria2c, which fetches an archive.org file over BitTorrent // (lib/archiveOrgTorrent-server.ts). Optional: without it the file is // downloaded directly. aria2cBin: string; // findmnt (util-linux): the read-only identity probe behind storage // locations — which volume a root is on, and where a UUID is mounted now. // See common/lib/storageVolumes.ts. Never required: every call fails open to // "identity unknown", which is also what a container answers. findmntBin: string; // udisksctl: the ONE storage subprocess that changes the machine — mounting // an attached-but-unmounted volume by UUID, offered on /storage. Optional in // the same way findmnt is; without it the button is simply not offered. udisksctlBin: string; // gallery-dl, the primary X/Twitter post fetcher (see // common/social/xGalleryDlFetcher.ts). A light headless subprocess — the same // shape the codebase already manages for yt-dlp and whisper. NOT bundled; // install it separately and point GALLERY_DL_BIN at it if it isn't on PATH. galleryDlBin: string; // parakeet (overlapping-segment stitching) app. parakeetBin is the standalone // wrapper script invoked as the app binary; parakeetCliBin is the underlying // parakeet-cli it drives; parakeetModel is the default .gguf model. parakeetBin: string; parakeetCliBin: string; parakeetModel: string; // Speaker-diarization wrapper script (scripts/diarize.mjs), invoked the way // parakeetBin is. Overridable via DIARIZE_BIN — which is also how the e2e // suite swaps in a fake diarizer. diarizeBin: string; // Base URL of the local ollama server, the local-GPU digest lane // (common/lib/digestApps.ts posts to `${ollamaUrl}/api/chat`). The FIRST // URL-valued entry in Paths, so it is normalized here the way // remoteTranscribe.ts normalizes a worker base: trailing slashes stripped, so // callers can always concatenate a leading-slash path. ollamaUrl: string; // The `claude` CLI, driving the opt-in metered digest lane (off by default — // settings.digest.remoteEnabled). Not bundled; install it separately and point // CLAUDE_BIN at it if it isn't on PATH. claudeBin: string; // The operator's PRIVATE config dir, outside the repo (~/.config/archilyzer // by default). Holds the two inputs of `archilyzer source publish` // (common/publish/source.ts), which are never committed: // sourceScrubFile — git-filter-repo `lhs==>rhs` rules for the mirror // sourceDenylistFile — literals the published source must never contain configDir: string; sourceScrubFile: string; sourceDenylistFile: string; // Where `source publish` makes its scratch clone (removed afterwards). sourceScratchDir: string; // The history pages' render cache (publish/sourceHistory.ts): the XDG // cache dir's archilyzer/source-history — ~/.cache unless XDG_CACHE_HOME // says otherwise (an empty one is unset, as the XDG spec has it). Never // inside the checkout or the public dir: the step renders without it there. sourceHistoryCacheDir: string; // stagit, which renders the source's history pages (/source/git/). An // operator-installed tool, never vendored: `stagit` on PATH, then // ~/.local/bin/stagit (publish/sourceHistory.ts resolveStagit). Without it // the publish goes on without the history pages. stagitBin: string; }; let cached: Paths | null = null; // Every path in getPaths() is built on the repo root, which findMonorepoRoot() // finds by walking up from `process.cwd()`. Turbopack evaluates `process.cwd()` // statically, and a path op on a value derived from it becomes an asset // reference — to every file under it when the path is a directory // (plans/FACTS.md, "A path joined from `process.cwd()` …"). So every join on // such a value goes through this one opted-out call. Nothing changes at run // time. The comment sits before a named first argument, not a spread: that is // the form Turbopack's own advice shows. function under(first: string, ...rest: string[]): string { return path.join(/* turbopackIgnore: true */ first, ...rest); } export function getPaths(): Paths { if (cached) return cached; const monorepoRoot = findMonorepoRoot(); const transcriptsDir = process.env.TRANSCRIPTS_DIR ?? under(monorepoRoot, "transcripts"); const exportDir = under(monorepoRoot, "export"); const exportPublicDir = process.env.EXPORT_PUBLIC_DIR ?? under(exportDir, "public"); // Staging sibling of the served public dir (so it lands inside the test data // root when EXPORT_PUBLIC_DIR is overridden). Not served; composed into // exportPublicDir per site by the build:site step. const exportIndexDir = process.env.EXPORT_INDEX_DIR ?? under(path.dirname(/* turbopackIgnore: true */ exportPublicDir), ".export-index"); const exportSharedDir = under(exportIndexDir, "shared"); const sitesDir = process.env.SITES_DIR ?? under(transcriptsDir, "sites"); const homepageDir = under(sitesDir, "_homepage"); const configDir = process.env.ARCHILYZER_CONFIG_DIR ?? path.join(os.homedir(), ".config", "archilyzer"); cached = { monorepoRoot, transcriptsDir, channelsDir: under(transcriptsDir, "channels"), savedVideosDir: process.env.SAVED_VIDEOS_DIR ?? under(transcriptsDir, "saved-videos"), sitesDir, homepageDir, homepageConfigFile: under(homepageDir, "homepage.json"), homepageChartTemplatesFile: under(homepageDir, "chart-templates.json"), jobsDir: under(transcriptsDir, ".jobs"), workerScratchDir: under(transcriptsDir, ".worker-scratch"), schedulerStateFile: under(transcriptsDir, ".scheduler", "state.json"), autoQueueStateFile: under(transcriptsDir, ".auto-queue", "state.json"), workerDefaultsFile: under(transcriptsDir, ".workers", "defaults.json"), widgetPresetsFile: under(transcriptsDir, ".widget", "presets.json"), lmdbPath: under(transcriptsDir, "index.mdb"), exportDir, exportPublicDir, exportSummariesDir: under(exportPublicDir, "summaries"), exportTranscriptsDir: under(exportPublicDir, "transcripts"), exportSubsDir: under(exportPublicDir, "subs"), exportPostsDir: under(exportPublicDir, "posts"), exportDigestsDir: under(exportPublicDir, "digests"), exportStatsDir: under(exportPublicDir, "stats"), exportIndexDir, exportSharedDir, exportSharedTranscriptsDir: under(exportSharedDir, "transcripts"), exportSharedSubsDir: under(exportSharedDir, "subs"), exportSharedPostsDir: under(exportSharedDir, "posts"), exportSharedDigestsDir: under(exportSharedDir, "digests"), exportSitesIndexDir: under(exportIndexDir, "sites"), exportBuildsDir: process.env.EXPORT_BUILDS_DIR ?? under(path.dirname(/* turbopackIgnore: true */ exportPublicDir), ".export-builds"), settingsFile: process.env.SETTINGS_FILE ?? under(monorepoRoot, "settings.json"), editorChangelogFile: process.env.EDITOR_CHANGELOG_FILE ?? under(monorepoRoot, "editor", "CHANGELOG.md"), exportChangelogFile: process.env.EXPORT_CHANGELOG_FILE ?? under(exportDir, "CHANGELOG.md"), chartsConfigFile: process.env.CHARTS_CONFIG_FILE ?? under(monorepoRoot, "chart-templates.json"), globalAliasesFile: process.env.SEARCH_ALIASES_FILE ?? under(transcriptsDir, "search-aliases.json"), globalTagsFile: process.env.CURATED_TAGS_FILE ?? under(transcriptsDir, TAGS_FILENAME), ytdlpBin: process.env.YTDLP_BIN ?? "yt-dlp", whisperBin: process.env.WHISPER_BIN ?? "whisper-cli", whisperModel: process.env.WHISPER_MODEL ?? path.join( os.homedir(), "whispercpp", "whisper.cpp", "models", "ggml-base.en.bin", ), ffmpegBin: process.env.FFMPEG_BIN ?? "ffmpeg", ffprobeBin: process.env.FFPROBE_BIN ?? "ffprobe", rsyncBin: process.env.RSYNC_BIN ?? "rsync", aria2cBin: process.env.ARIA2C_BIN ?? "aria2c", findmntBin: process.env.FINDMNT_BIN ?? "findmnt", udisksctlBin: process.env.UDISKSCTL_BIN ?? "udisksctl", galleryDlBin: process.env.GALLERY_DL_BIN ?? "gallery-dl", parakeetBin: process.env.PARAKEET_STITCH_BIN ?? under(monorepoRoot, "scripts", "parakeet-stitch.mjs"), parakeetCliBin: process.env.PARAKEET_CLI ?? "parakeet-cli", parakeetModel: process.env.PARAKEET_MODEL ?? "", // Speaker-diarization wrapper, same shape as parakeetBin: a script we own, // overridable by env so the engine behind it can be swapped (sherpa-onnx // today, pyannote later) without touching any caller. diarizeBin: process.env.DIARIZE_BIN ?? under(monorepoRoot, "scripts", "diarize.mjs"), ollamaUrl: (process.env.OLLAMA_URL ?? "http://127.0.0.1:11434").replace( /\/+$/, "", ), claudeBin: process.env.CLAUDE_BIN ?? "claude", configDir, sourceScrubFile: process.env.SOURCE_SCRUB_FILE ?? path.join(configDir, "source-scrub.txt"), sourceDenylistFile: process.env.SOURCE_DENYLIST_FILE ?? path.join(configDir, "source-denylist.txt"), sourceScratchDir: process.env.ARCHILYZER_SOURCE_SCRATCH ?? os.tmpdir(), sourceHistoryCacheDir: path.join( process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "archilyzer", "source-history", ), stagitBin: process.env.STAGIT_BIN ?? "stagit", }; return cached; } // Walks up from the working directory to the workspace root; falls back to // the working directory itself (an app's own directory). Every path op on // these cwd-derived values opts out of Turbopack's tracing, so what the // fallback evaluates to at build time never becomes an asset reference. function findMonorepoRoot(): string { const start = path.resolve(/* turbopackIgnore: true */ process.cwd()); let dir = start; for (let i = 0; i < 8; i++) { if (fs.existsSync(path.join(/* turbopackIgnore: true */ dir, "pnpm-workspace.yaml"))) return dir; const parent = path.dirname(/* turbopackIgnore: true */ dir); if (parent === dir) break; dir = parent; } return start; }