diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bee7781ea7..9aa0edfc56 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1920,6 +1920,9 @@ default 3); `--json` prints the results as JSON. ## Keep the environment healthy +HyperFrames keeps what it downloads and builds (the headless browser, models, optional packages, fonts and project +history) under `~/.cache/hyperframes`. Set `HYPERFRAMES_CACHE_DIR` to an absolute path to keep it somewhere else. + ### `doctor` Check the machine for everything a render needs. diff --git a/packages/cli/src/audio/analysis/providers/apple.ts b/packages/cli/src/audio/analysis/providers/apple.ts index 36e24a6348..ce3147147b 100644 --- a/packages/cli/src/audio/analysis/providers/apple.ts +++ b/packages/cli/src/audio/analysis/providers/apple.ts @@ -1,10 +1,10 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, rename, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import type { AudioAnalysisProvider, AudioSignal } from "@hyperframes/core/audio-analysis"; import { AVAILABLE, runFile, unavailable, withWavFile } from "./shared.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; const SOUND_ANALYSIS = "/System/Library/Frameworks/SoundAnalysis.framework"; const MUSIC_UNDERSTANDING_MACOS = 27; @@ -62,7 +62,7 @@ function atLeastOneWindow(signal: AudioSignal): AudioSignal { }; } -const helperDir = () => join(homedir(), ".cache", "hyperframes", "audio-helpers"); +const helperDir = () => hyperframesCacheDir("audio-helpers"); function helperPath(name: string, source: string): string { const hash = createHash("sha256").update(source).digest("hex").slice(0, 12); diff --git a/packages/cli/src/background-removal/coremlTemp.ts b/packages/cli/src/background-removal/coremlTemp.ts index 1b92779995..3d1fb33f5f 100644 --- a/packages/cli/src/background-removal/coremlTemp.ts +++ b/packages/cli/src/background-removal/coremlTemp.ts @@ -1,17 +1,12 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; import { basename, join } from "node:path"; import { processIsAlive } from "../utils/processTree.js"; const COMPILED_MODEL = /^onnxruntime-.*\.mlmodelc?$/; -const COREML_TEMP_MANIFESTS = join( - homedir(), - ".cache", - "hyperframes", - "background-removal", - "coreml-temp", -); +const COREML_TEMP_MANIFESTS = hyperframesCacheDir("background-removal", "coreml-temp"); interface Manifest { pid: number; diff --git a/packages/cli/src/background-removal/manager.ts b/packages/cli/src/background-removal/manager.ts index d149c0e2ea..3a37b40dd2 100644 --- a/packages/cli/src/background-removal/manager.ts +++ b/packages/cli/src/background-removal/manager.ts @@ -1,9 +1,10 @@ import { existsSync, mkdirSync } from "node:fs"; -import { homedir, platform, arch } from "node:os"; +import { platform, arch } from "node:os"; import { join } from "node:path"; import { downloadFile } from "../utils/download.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; -export const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "background-removal", "models"); +export const MODELS_DIR = hyperframesCacheDir("background-removal", "models"); export const DEFAULT_MODEL = "u2net_human_seg" as const; export type ModelId = typeof DEFAULT_MODEL; diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 380e4b53a4..831b18ebaf 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -24,6 +24,7 @@ import { BrowserDownloadStalledError, installInChild } from "./installInChild.js import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { runRenderSetupWorker } from "../utils/cancellableProcess.js"; import { unzipArchive } from "./unzipArchive.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; type PuppeteerBrowsers = typeof import("@puppeteer/browsers"); @@ -39,8 +40,8 @@ async function loadPuppeteerBrowsers(): Promise { } } -const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes"); -const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome"); +const CACHE_ROOT_DIR = hyperframesCacheDir(); +const CACHE_DIR = hyperframesCacheDir("chrome"); const ARCHIVE_PLUS_UNPACKED_SHELL_MB = 512; // Puppeteer's managed cache — where `@puppeteer/browsers install // chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's diff --git a/packages/cli/src/cloud/offload.ts b/packages/cli/src/cloud/offload.ts index 11516c0110..459bfee2ba 100644 --- a/packages/cli/src/cloud/offload.ts +++ b/packages/cli/src/cloud/offload.ts @@ -15,7 +15,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { basename, dirname, extname, join, posix, relative, resolve } from "node:path"; import { promisify } from "node:util"; import { parseHTML } from "linkedom"; @@ -41,6 +41,7 @@ import { stagedSize, TEXT_FILE_EXTS, } from "../utils/publishProject.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; const execFileAsync = promisify(execFile); @@ -202,7 +203,7 @@ interface CacheEntry { } function defaultOffloadCachePath(): string { - return join(homedir(), ".cache", "hyperframes", "cloud-assets.json"); + return hyperframesCacheDir("cloud-assets.json"); } function readCache(path: string): Record { @@ -652,7 +653,7 @@ async function prepareEntryGifs( downloadDir: outputDir, outputDir, outputSrcPrefix: posix.relative(posix.dirname(entryPath), GIF_PREFIX), - cacheDir: join(homedir(), ".cache", "hyperframes", "animated-gif"), + cacheDir: hyperframesCacheDir("animated-gif"), sourceAssets, }); if (result.preparedGifs.length === 0) return; diff --git a/packages/cli/src/commands/lambda/prebuiltHandler.ts b/packages/cli/src/commands/lambda/prebuiltHandler.ts index 81d0f72fa4..2a6df0fae6 100644 --- a/packages/cli/src/commands/lambda/prebuiltHandler.ts +++ b/packages/cli/src/commands/lambda/prebuiltHandler.ts @@ -11,14 +11,14 @@ import { renameSync, unlinkSync, } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { downloadFile } from "../../utils/download.js"; import { isMissingPackageError } from "../../utils/missingPackage.js"; import { VERSION } from "../../version.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; const DOCS_URL = "https://hyperframes.heygen.com/deploy/aws-lambda"; -const HANDLER_CACHE_DIR = join(homedir(), ".cache", "hyperframes", "lambda-handler"); +const HANDLER_CACHE_DIR = hyperframesCacheDir("lambda-handler"); type AdapterSdk = typeof import("@hyperframes/aws-lambda/sdk"); diff --git a/packages/cli/src/measure/opencv.ts b/packages/cli/src/measure/opencv.ts index 6cdcb4f541..654571e136 100644 --- a/packages/cli/src/measure/opencv.ts +++ b/packages/cli/src/measure/opencv.ts @@ -3,14 +3,14 @@ // Haar face models, pinned by size and sha256, into ~/.cache/hyperframes/opencv//. import { mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; import { downloadToFile } from "../cloud/download.js"; import { verifies, type ModelFile } from "../whisper/modelFile.js"; import { Fix } from "./media.js"; const VERSION = "4.10.0"; -export const OPENCV_DIR = join(homedir(), ".cache", "hyperframes", "opencv", VERSION); +export const OPENCV_DIR = hyperframesCacheDir("opencv", VERSION); interface Pinned extends ModelFile { url: string; diff --git a/packages/cli/src/tts/manager.ts b/packages/cli/src/tts/manager.ts index 317f8d307b..6181aedf43 100644 --- a/packages/cli/src/tts/manager.ts +++ b/packages/cli/src/tts/manager.ts @@ -1,9 +1,9 @@ import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { downloadFile } from "../utils/download.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; -const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "tts"); +const CACHE_DIR = hyperframesCacheDir("tts"); const MODELS_DIR = join(CACHE_DIR, "models"); const VOICES_DIR = join(CACHE_DIR, "voices"); diff --git a/packages/cli/src/tts/synthesize.ts b/packages/cli/src/tts/synthesize.ts index 4ec225a248..c1b0390605 100644 --- a/packages/cli/src/tts/synthesize.ts +++ b/packages/cli/src/tts/synthesize.ts @@ -2,7 +2,6 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; import { join, dirname, basename } from "node:path"; -import { homedir } from "node:os"; import { ensureModel, ensureVoices, @@ -12,6 +11,7 @@ import { } from "./manager.js"; import { findPython, hasPythonPackage, workingPythonEnv } from "./python.js"; import { writeNewFileSync } from "../utils/writeNewFile.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; // --------------------------------------------------------------------------- // Inline Python script for Kokoro synthesis @@ -115,7 +115,7 @@ const ESPEAK_LANG_OVERRIDES: Partial> = { // Cache the script to avoid rewriting it on every invocation. // The filename carries a version suffix so older installs automatically // upgrade when the script body changes (e.g., adding the `lang` kwarg). -const SCRIPT_DIR = join(homedir(), ".cache", "hyperframes", "tts"); +const SCRIPT_DIR = hyperframesCacheDir("tts"); const SCRIPT_PATH = join(SCRIPT_DIR, "synth-v4.py"); function ensureSynthScript(): string { diff --git a/packages/cli/src/utils/optionalPackages.test.ts b/packages/cli/src/utils/optionalPackages.test.ts index a93974dc64..c98bfcdd2f 100644 --- a/packages/cli/src/utils/optionalPackages.test.ts +++ b/packages/cli/src/utils/optionalPackages.test.ts @@ -611,9 +611,8 @@ describe("install", () => { function setup() { const cache = mkdtempSync(join(tmpdir(), "hf-optional-")); const dir = join(cache, "pkg@1.0.0"); - const stubNpm = async (args: string[]) => { + const stubNpm = async (_args: string[], prefix: string) => { await new Promise((r) => setTimeout(r, 50)); - const prefix = args[args.indexOf("--prefix") + 1] as string; if (!existsSync(prefix)) throw new Error("ENOENT: staging dir vanished mid-install"); mkdirSync(join(prefix, "node_modules", name), { recursive: true }); writeFileSync(join(prefix, "node_modules", name, "package.json"), "{}"); @@ -621,6 +620,22 @@ describe("install", () => { return { cache, dir, stubNpm }; } + it("runs npm inside the staging dir, with no path on its command line", async () => { + const { cache, dir } = setup(); + const seen: { args: string[]; cwd: string }[] = []; + try { + await install(dir, name, version, async (args, cwd) => { + seen.push({ args, cwd }); + }); + expect(seen).toHaveLength(1); + expect(seen[0]!.args).not.toContain("--prefix"); + expect(seen[0]!.args.some((arg) => arg.includes(cache))).toBe(false); + expect(seen[0]!.cwd.startsWith(`${dir}.tmp-`)).toBe(true); + } finally { + rmSync(cache, { recursive: true, force: true }); + } + }); + it("gives each overlapping install in one process its own staging dir", async () => { const { cache, dir, stubNpm } = setup(); try { diff --git a/packages/cli/src/utils/optionalPackages.ts b/packages/cli/src/utils/optionalPackages.ts index 81ae64fc76..5126ddc5a3 100644 --- a/packages/cli/src/utils/optionalPackages.ts +++ b/packages/cli/src/utils/optionalPackages.ts @@ -12,9 +12,9 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; -import { homedir } from "node:os"; import { basename, dirname, join, sep } from "node:path"; import { buildNpmCommand } from "./npxCommand.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; /** Module type of each optional package; the keys are the only names the loader accepts. */ export interface OptionalPackageModules { @@ -37,7 +37,7 @@ export const PINNED_PACKAGES = { type PinnedPackage = keyof typeof PINNED_PACKAGES; -export const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "optional"); +export const CACHE_DIR = hyperframesCacheDir("optional"); /** Installed on first use only when a host bundled the CLI without its sharp dependency. */ export const SHARP_VERSION = "0.35.3"; @@ -286,9 +286,9 @@ export function loadBesideCli(name: PinnedPackage, cliUrl = import.meta.url): un return copy?.resolution === "entry" ? createRequire(cliUrl)(copy.path) : null; } -export function runNpm(args: string[], signal?: AbortSignal): Promise { +export function runNpm(args: string[], signal?: AbortSignal, cwd?: string): Promise { const npm = buildNpmCommand(args); - const child = spawn(npm.command, npm.args, { stdio: ["ignore", "pipe", "pipe"], signal }); + const child = spawn(npm.command, npm.args, { stdio: ["ignore", "pipe", "pipe"], signal, cwd }); return new Promise((resolve, reject) => { let output = ""; child.stdout.on("data", (chunk) => (output += chunk)); @@ -340,26 +340,27 @@ export async function install( dir: string, name: string, version: string, - run: (args: string[]) => Promise = runNpm, + run: (args: string[], cwd: string) => Promise = (args, cwd) => runNpm(args, undefined, cwd), ): Promise { sweepStaleStaging(dir); const staging = `${dir}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`; mkdirSync(staging, { recursive: true }); writeFileSync(join(staging, "package.json"), "{}"); try { - await run([ - "install", - `${name}@${version}`, - "--prefix", + await run( + [ + "install", + `${name}@${version}`, + "--no-audit", + "--no-fund", + "--loglevel=error", + // A first-use install is interactive, not a CI resolve: fail fast and name the + // manual command instead of sitting through npm's default multi-minute backoff. + "--fetch-retries=0", + "--fetch-timeout=20000", + ], staging, - "--no-audit", - "--no-fund", - "--loglevel=error", - // A first-use install is interactive, not a CI resolve: fail fast and name the - // manual command instead of sitting through npm's default multi-minute backoff. - "--fetch-retries=0", - "--fetch-timeout=20000", - ]); + ); if (isInstalled(dir, name)) return; rmSync(dir, { recursive: true, force: true }); renameSync(staging, dir); diff --git a/packages/cli/src/warmCheckProtocol.ts b/packages/cli/src/warmCheckProtocol.ts index 1c46924a59..b0fcef0ec4 100644 --- a/packages/cli/src/warmCheckProtocol.ts +++ b/packages/cli/src/warmCheckProtocol.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { Writable } from "node:stream"; import { fileURLToPath } from "node:url"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; export const WARM_IDLE_MS = 5 * 60_000; export const HANDSHAKE_TIMEOUT_MS = 2_000; @@ -50,7 +51,7 @@ export function warmCheckDirectory(env = process.env): string | undefined { ? join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "hyperframes", "warm") : env.XDG_RUNTIME_DIR ? join(env.XDG_RUNTIME_DIR, "hyperframes-warm") - : join(homedir(), ".cache", "hyperframes", "warm"); + : hyperframesCacheDir("warm"); try { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { diff --git a/packages/cli/src/whisper/manager.install.test.ts b/packages/cli/src/whisper/manager.install.test.ts index f959e98450..3a2acf4e28 100644 --- a/packages/cli/src/whisper/manager.install.test.ts +++ b/packages/cli/src/whisper/manager.install.test.ts @@ -38,3 +38,28 @@ it("never installs when the runtime vanishes after discovery and installation is rmSync(dir, { recursive: true, force: true }); } }); + +it("clones whisper.cpp inside its build dir, with no cache path on the command line", async () => { + const cache = mkdtempSync(join(tmpdir(), "hf-whisper-cache-")); + vi.stubEnv("HYPERFRAMES_CACHE_DIR", cache); + vi.resetModules(); + const { ensureWhisper: ensure, WhisperUnavailableError: Unavailable } = + await import("./manager.js"); + calls.exec.mockImplementation((command: string, args: string[]) => { + const lookup = command === "which" || command === "where"; + if (lookup && (args[0] === "brew" || args[0] === "whisper-cli")) + throw new Error("not available"); + if (lookup) return `/bin/${args[0]}\n`; + if (command === "git" && args[0] === "clone") throw new Error("stop after the clone"); + return ""; + }); + try { + await expect(ensure()).rejects.toBeInstanceOf(Unavailable); + const clone = calls.exec.mock.calls.find(([c, a]) => c === "git" && a[0] === "clone"); + const [, args, options] = clone as [string, string[], { cwd?: string }]; + expect(args.some((arg) => arg.includes(cache))).toBe(false); + expect(options.cwd).toBe(join(cache, "whisper", "whisper.cpp")); + } finally { + rmSync(cache, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/whisper/manager.ts b/packages/cli/src/whisper/manager.ts index 97aa68cb57..f1a69bc4be 100644 --- a/packages/cli/src/whisper/manager.ts +++ b/packages/cli/src/whisper/manager.ts @@ -1,14 +1,15 @@ // fallow-ignore-file code-duplication complexity import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; -import { homedir, platform } from "node:os"; +import { platform } from "node:os"; import { join } from "node:path"; import { findFFmpeg } from "../browser/ffmpeg.js"; import { downloadFile } from "../utils/download.js"; import { stoppableExecFile } from "../utils/stoppableExecFile.js"; import { verifies, type ModelFile } from "./modelFile.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; -const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models"); +const MODELS_DIR = hyperframesCacheDir("whisper", "models"); const DEFAULT_MODEL = "small.en"; export type WhisperSource = "env" | "system" | "brew" | "build"; @@ -144,7 +145,7 @@ function findFromSystem(): WhisperResult | undefined { // --- Build from source ------------------------------------------------------ -const BUILD_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "whisper.cpp"); +const BUILD_DIR = hyperframesCacheDir("whisper", "whisper.cpp"); const WHISPER_REPO = "https://github.com/ggml-org/whisper.cpp.git"; function findBuiltBinary(): WhisperResult | undefined { @@ -168,10 +169,9 @@ async function buildFromSource( if (!existsSync(BUILD_DIR)) { onProgress?.("Downloading whisper.cpp..."); - mkdirSync(join(homedir(), ".cache", "hyperframes", "whisper"), { - recursive: true, - }); - await stoppableExecFile("git", ["clone", "--depth", "1", WHISPER_REPO, BUILD_DIR], { + mkdirSync(BUILD_DIR, { recursive: true }); + await stoppableExecFile("git", ["clone", "--depth", "1", WHISPER_REPO, "."], { + cwd: BUILD_DIR, stdio: "ignore", timeout: 60_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, diff --git a/packages/cli/src/whisper/sherpa.test.ts b/packages/cli/src/whisper/sherpa.test.ts index 32342c35c9..428166df6d 100644 --- a/packages/cli/src/whisper/sherpa.test.ts +++ b/packages/cli/src/whisper/sherpa.test.ts @@ -92,8 +92,8 @@ describe("installSherpaRuntime", () => { expect(await sherpaRuntimeLoadError(dir)).toBe( "libonnxruntime.so: cannot open shared object", ); - const run = vi.fn(async (args: string[]) => - fakeRuntime(args[args.indexOf("--prefix") + 1]!, LOADS), + const run = vi.fn(async (_args: string[], _signal?: AbortSignal, cwd?: string) => + fakeRuntime(cwd!, LOADS), ); expect(await installSherpaRuntime({ run, dir })).toEqual({ @@ -125,8 +125,8 @@ describe("installSherpaRuntime", () => { it("fails with the loader's error when the reinstalled runtime still does not load", async () => { const dir = mkdtempSync(join(tmpdir(), "hf-sherpa-runtime-")); try { - const run = vi.fn(async (args: string[]) => - fakeRuntime(args[args.indexOf("--prefix") + 1]!, BROKEN), + const run = vi.fn(async (_args: string[], _signal?: AbortSignal, cwd?: string) => + fakeRuntime(cwd!, BROKEN), ); await expect(installSherpaRuntime({ run, dir })).rejects.toThrow( "The sherpa-onnx runtime was reinstalled but still does not load (libonnxruntime.so: cannot open shared object). Use --engine whisper for now.", diff --git a/packages/cli/src/whisper/sherpa.ts b/packages/cli/src/whisper/sherpa.ts index f18a6c0937..2940db8c15 100644 --- a/packages/cli/src/whisper/sherpa.ts +++ b/packages/cli/src/whisper/sherpa.ts @@ -1,7 +1,7 @@ import { execFile, spawn, type ExecFileException } from "node:child_process"; import { randomUUID } from "node:crypto"; import { mkdirSync, renameSync, rmSync, statSync } from "node:fs"; -import { homedir } from "node:os"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; import { join } from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -45,13 +45,7 @@ export const SHERPA_RUNTIME_DIR = join( const MODEL_REPO = "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"; const MODEL_REVISION = "2bda32ec70b097a55adaa07d9a7173915b43cc78"; -export const PARAKEET_MODEL_DIR = join( - homedir(), - ".cache", - "hyperframes", - "parakeet", - "parakeet-tdt-0.6b-v3-int8", -); +export const PARAKEET_MODEL_DIR = hyperframesCacheDir("parakeet", "parakeet-tdt-0.6b-v3-int8"); export type { ModelFile }; @@ -252,7 +246,7 @@ export async function installSherpaRuntime({ // install() keeps any dir holding the runtime manifest, so a broken one goes first. rmSync(dir, { recursive: true, force: true }); const native = sherpaPlatformPackage(); - await install(dir, RUNTIME, RUNTIME_VERSION, (args) => run([...args, native], signal)); + await install(dir, RUNTIME, RUNTIME_VERSION, (args, cwd) => run([...args, native], signal, cwd)); const selected = await cachedRuntime(dir, 60_000, signal); if (selected.status === "unavailable") { throw new Error( diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index d901470b2a..d1aca87de1 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -68,6 +68,12 @@ "types": "./dist/atomicFile.d.ts", "environments": ["bun", "node"] }, + "./cache-dir": { + "source": "./src/cacheDir.ts", + "runtime": "./dist/cacheDir.js", + "types": "./dist/cacheDir.d.ts", + "environments": ["bun", "node"] + }, "./remove-tree": { "source": "./src/removeTree.ts", "runtime": "./dist/removeTree.js", diff --git a/packages/core/package.json b/packages/core/package.json index 94c8953dea..194eb67ab8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,6 +80,12 @@ "import": "./src/atomicFile.ts", "types": "./src/atomicFile.ts" }, + "./cache-dir": { + "bun": "./src/cacheDir.ts", + "node": "./dist/cacheDir.js", + "import": "./src/cacheDir.ts", + "types": "./src/cacheDir.ts" + }, "./remove-tree": { "bun": "./src/removeTree.ts", "node": "./dist/removeTree.js", @@ -662,6 +668,10 @@ "import": "./dist/atomicFile.js", "types": "./dist/atomicFile.d.ts" }, + "./cache-dir": { + "import": "./dist/cacheDir.js", + "types": "./dist/cacheDir.d.ts" + }, "./remove-tree": { "import": "./dist/removeTree.js", "types": "./dist/removeTree.d.ts" diff --git a/packages/core/src/cacheDir.test.ts b/packages/core/src/cacheDir.test.ts new file mode 100644 index 0000000000..0edded2d1e --- /dev/null +++ b/packages/core/src/cacheDir.test.ts @@ -0,0 +1,59 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hyperframesCacheDir } from "./cacheDir.js"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("hyperframesCacheDir", () => { + it("defaults to ~/.cache/hyperframes", () => { + vi.stubEnv("HYPERFRAMES_CACHE_DIR", ""); + expect(hyperframesCacheDir()).toBe(join(homedir(), ".cache", "hyperframes")); + expect(hyperframesCacheDir("chrome")).toBe(join(homedir(), ".cache", "hyperframes", "chrome")); + }); + + it("follows HYPERFRAMES_CACHE_DIR, read on each call, a relative one resolved", () => { + const elsewhere = join(tmpdir(), "elsewhere-cache"); + vi.stubEnv("HYPERFRAMES_CACHE_DIR", elsewhere); + expect(hyperframesCacheDir("whisper", "models")).toBe(join(elsewhere, "whisper", "models")); + vi.stubEnv("HYPERFRAMES_CACHE_DIR", "rel/cache"); + expect(hyperframesCacheDir("tts")).toBe(join(resolve("rel/cache"), "tts")); + }); +}); + +/** Source files under every package's src, tests and test helpers left out. */ +function sources(dir: string): string[] { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(m?[jt]sx?)$/.test(entry.name)) + .filter((entry) => !/\.test[.-]|__tests__/.test(join(entry.parentPath, entry.name))) + .map((entry) => join(entry.parentPath, entry.name)); +} + +it("every cache path in every package goes through hyperframesCacheDir", () => { + const packages = resolve(__dirname, "..", ".."); + const built: string[] = []; + for (const name of readdirSync(packages)) { + let files: string[]; + try { + files = sources(join(packages, name, "src")); + } catch { + continue; // a package without src + } + for (const file of files) { + if (file.endsWith(join("core", "src", "cacheDir.ts"))) continue; + // Prose about the default path is fine; code that builds it is not. + const code = readFileSync(file, "utf8") + .split("\n") + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join("\n"); + if ( + /["'`]\.cache["'`]\s*,\s*["'`]hyperframes["'`]|["'`][^"'`\n]*\.cache\/hyperframes/.test( + code, + ) + ) + built.push(file.slice(packages.length + 1)); + } + } + expect(built).toEqual([]); +}); diff --git a/packages/core/src/cacheDir.ts b/packages/core/src/cacheDir.ts new file mode 100644 index 0000000000..6d364613a2 --- /dev/null +++ b/packages/core/src/cacheDir.ts @@ -0,0 +1,7 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +export function hyperframesCacheDir(...parts: string[]): string { + const named = process.env["HYPERFRAMES_CACHE_DIR"]; + return join(named ? resolve(named) : join(homedir(), ".cache", "hyperframes"), ...parts); +} diff --git a/packages/core/src/fonts/deterministicFonts.ts b/packages/core/src/fonts/deterministicFonts.ts index 2bb382872f..1eb00e0904 100644 --- a/packages/core/src/fonts/deterministicFonts.ts +++ b/packages/core/src/fonts/deterministicFonts.ts @@ -8,7 +8,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "./aliases.js"; @@ -25,6 +25,7 @@ import { type FontDiagnostics, type FontDiagPath, } from "./fontDiagnostics.js"; +import { hyperframesCacheDir } from "../cacheDir.js"; export type { FontDiagnostics }; @@ -1123,7 +1124,7 @@ function resolveFontCacheRoot(): string { lambdaFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-")); return lambdaFontCacheRoot; } - return join(homedir(), ".cache", "hyperframes", "fonts"); + return hyperframesCacheDir("fonts"); } // Chrome UA triggers woff2 responses from Google Fonts CSS API diff --git a/packages/core/src/fonts/fontCompression.ts b/packages/core/src/fonts/fontCompression.ts index bbeee94455..8bf56309fd 100644 --- a/packages/core/src/fonts/fontCompression.ts +++ b/packages/core/src/fonts/fontCompression.ts @@ -10,8 +10,9 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { hyperframesCacheDir } from "../cacheDir.js"; const { compress } = wawoff2 as { compress: (input: Buffer | Uint8Array) => Promise; @@ -41,7 +42,7 @@ function defaultCacheDir(): string { process.env.HYPERFRAMES_FONT_CACHE_DIR ?? (process.env.AWS_LAMBDA_FUNCTION_NAME ? join(tmpdir(), "hyperframes", "fonts") - : join(homedir(), ".cache", "hyperframes", "fonts")); + : hyperframesCacheDir("fonts")); return join(root, "local-compression-v1"); } diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index d26bfc048c..9d91a29998 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -46,6 +46,7 @@ export type { BrowserPoolState, CaptureMode, } from "./browserLeasePool.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; let _puppeteer: PuppeteerNode | undefined; @@ -271,7 +272,7 @@ export function resolveHeadlessShellPath( } return fromEnv.path; } - const ours = join(homedir(), ".cache", "hyperframes", "chrome", "chrome-headless-shell"); + const ours = hyperframesCacheDir("chrome", "chrome-headless-shell"); const shared = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell"); return ( findManagedHeadlessShell(ours) ?? diff --git a/packages/inspector/src/service.ts b/packages/inspector/src/service.ts index ab09b1a39f..8c96383fff 100644 --- a/packages/inspector/src/service.ts +++ b/packages/inspector/src/service.ts @@ -22,7 +22,6 @@ import { readdirSync, } from "node:fs"; import { resolve, join, extname, sep } from "node:path"; -import { homedir } from "node:os"; import { getHyperframeRuntimeScript } from "../../core/src/generated/runtime-inline"; import puppeteer, { type Browser, type Page } from "puppeteer-core"; import { @@ -32,6 +31,7 @@ import { import { closeServer } from "./pageServer"; import { Sources, fingerprint } from "./sources"; import type { InspectRequest, Snippet } from "./types"; +import { hyperframesCacheDir } from "../../core/src/cacheDir"; const pageRunner = (page: Page): PageRunner => ({ run: (fn: (arg: A) => R, arg: A) => @@ -59,7 +59,7 @@ function chromePath() { "Set HF_INSPECT_CHROME to an installed Chrome executable; inspection does not download a browser.", ); } - const root = join(homedir(), ".cache/hyperframes/chrome/chrome-headless-shell"); + const root = hyperframesCacheDir("chrome", "chrome-headless-shell"); if (existsSync(root)) for (const version of readdirSync(root).sort().reverse()) { const dir = join(root, version); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index d1e22d2cae..87d8ea3e1f 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -12,7 +12,6 @@ import { realpathSync, rmSync, } from "node:fs"; -import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { replaceFileAtomically } from "@hyperframes/core/atomic-file"; import { @@ -64,9 +63,10 @@ import { type LogRecord, type Manifest, } from "./historyLog.js"; +import { hyperframesCacheDir } from "@hyperframes/core/cache-dir"; /** Where hosts keep project histories unless told otherwise: outside every project, so no tidy-up takes one away. */ -export const DEFAULT_HISTORY_ROOT = join(homedir(), ".cache", "hyperframes", "history"); +export const DEFAULT_HISTORY_ROOT = hyperframesCacheDir("history"); export interface ProjectHistoryOptions { projectDir: string;