mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): install onnxruntime-node and google-genai on first use (#4156)
* fix(cli): install onnxruntime-node and google-genai on first use Both carry deprecated transitive dependencies (boolean, node-domexception) that showed up as npm warnings on every npx hyperframes install. They move to devDependencies (types and the Intel-binding check still need them at build time; the bundler externalizes them) and load through a new on-demand loader that installs into the CLI's own cache dir the first time a feature needs them, with one stderr line, no prompt, and a manual-install command named in the error if the network is unavailable. hyperframes doctor lists each as installed (with version) or not-installed-yet. * fix(cli): sweep stale install staging and bound network retries * fix(cli): optional-package install only sweeps staging dirs whose process is dead Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir whose pid is alive, so concurrent first runs no longer delete each other's install. The install error suggests the network only for network failures, and the manifest path has one owner. install() takes an injectable npm runner so tests drive the real code. * fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no longer aborts a viable install. * fix(cli): optional-package sweep names a stale dir it could not remove A locked leftover is still skipped so the install proceeds, but the warning makes it visible. * docs(cli): shorten the install docblock to the comment limit Comment-only change.
This commit is contained in:
@@ -75,7 +75,6 @@
|
||||
"giget": "^3.2.0",
|
||||
"hono": "^4.0.0",
|
||||
"ignore": "^5.3.2",
|
||||
"onnxruntime-node": "1.21.1",
|
||||
"open": "^10.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -84,6 +83,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@google/genai": "1.52.0",
|
||||
"@hyperframes/aws-lambda": "workspace:*",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/engine": "workspace:*",
|
||||
@@ -99,15 +99,13 @@
|
||||
"@types/node": "^25.0.10",
|
||||
"ajv": "^8.20.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"onnxruntime-node": "1.21.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@google/genai": "^1.50.1",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
|
||||
@@ -374,7 +374,8 @@ The model is `u2net_human_seg` (MIT, ~168 MB ONNX). It downloads to
|
||||
after that. Peak inference RAM is about 1.5 GB.
|
||||
|
||||
`--device auto` picks CoreML on Apple Silicon, CUDA where available, CPU
|
||||
otherwise. The CLI bundles the CPU build of `onnxruntime-node`; for CUDA, set
|
||||
otherwise. The first run installs the CPU build of `onnxruntime-node` into
|
||||
`~/.cache/hyperframes/optional/` (one line on stderr, once, no prompt); for CUDA, set
|
||||
`HYPERFRAMES_CUDA=1` and supply a GPU-enabled build.
|
||||
|
||||
<Tip>
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"giget": "^3.2.0",
|
||||
"hono": "^4.0.0",
|
||||
"ignore": "^5.3.2",
|
||||
"onnxruntime-node": "1.21.1",
|
||||
"open": "^10.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -51,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@google/genai": "1.52.0",
|
||||
"@hyperframes/aws-lambda": "workspace:*",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/engine": "workspace:*",
|
||||
@@ -66,15 +66,13 @@
|
||||
"@types/node": "^25.0.10",
|
||||
"ajv": "^8.20.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"onnxruntime-node": "1.21.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@google/genai": "^1.50.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OPTIONAL_PACKAGES } from "../utils/optionalPackages.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -39,12 +40,18 @@ describe("background-removal native dependency compatibility", () => {
|
||||
const packagePath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
||||
const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
// 1.22+ pulls vulnerable adm-zip <0.6.0, while later releases also drop
|
||||
// the Intel macOS binary. Return to a newer ONNX release once it satisfies
|
||||
// both the clean dependency graph and the six-platform binary contract.
|
||||
expect(packageJson.dependencies?.["onnxruntime-node"]).toBe("1.21.1");
|
||||
expect(OPTIONAL_PACKAGES["onnxruntime-node"]).toBe("1.21.1");
|
||||
|
||||
// Installed on first use, so it must not ride along with `npx hyperframes`; the devDependency
|
||||
// only supplies types and the binding listing below, at the same pinned version.
|
||||
expect(packageJson.dependencies).not.toHaveProperty("onnxruntime-node");
|
||||
expect(packageJson.devDependencies).toMatchObject(OPTIONAL_PACKAGES);
|
||||
|
||||
const bindingEntry = require.resolve("onnxruntime-node");
|
||||
const packageRoot = join(dirname(bindingEntry), "..");
|
||||
|
||||
@@ -126,33 +126,29 @@ describe("background-removal/inference — applyMask invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// onnxruntime-node and sharp are optional native modules; when their platform
|
||||
// binary can't load, createSession must fail with an actionable install hint
|
||||
// (and before touching the network / model download), not a raw module error.
|
||||
// sharp is an optional native module and onnxruntime-node installs on first use; when either
|
||||
// can't load, createSession must fail with an actionable error (and before touching the network
|
||||
// / model download), not a raw module error.
|
||||
describe("background-removal/inference — missing optional native modules", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("createSession throws an actionable error when onnxruntime-node can't load", async () => {
|
||||
vi.doMock("onnxruntime-node", () => {
|
||||
throw new Error("Cannot find module 'onnxruntime-node'");
|
||||
});
|
||||
it("createSession asks the optional-package loader for onnxruntime-node and surfaces its error", async () => {
|
||||
const loadOptionalPackage = vi.fn().mockRejectedValue(new Error("run npm install it yourself"));
|
||||
vi.doMock("../utils/optionalPackages.js", () => ({ loadOptionalPackage }));
|
||||
const { createSession } = await import("./inference.js");
|
||||
await expect(createSession()).rejects.toThrow(
|
||||
/onnxruntime-node.*isn't available[\s\S]*npm i onnxruntime-node/,
|
||||
);
|
||||
vi.doUnmock("onnxruntime-node");
|
||||
await expect(createSession()).rejects.toThrow("run npm install it yourself");
|
||||
expect(loadOptionalPackage).toHaveBeenCalledWith("onnxruntime-node", "background removal");
|
||||
vi.doUnmock("../utils/optionalPackages.js");
|
||||
});
|
||||
|
||||
it("createSession throws an actionable error when sharp can't load", async () => {
|
||||
vi.doMock("onnxruntime-node", () => ({ InferenceSession: {}, Tensor: {} }));
|
||||
vi.doMock("sharp", () => {
|
||||
throw new Error("Could not load the sharp module");
|
||||
});
|
||||
const { createSession } = await import("./inference.js");
|
||||
await expect(createSession()).rejects.toThrow(/sharp.*isn't available[\s\S]*npm i sharp/);
|
||||
vi.doUnmock("onnxruntime-node");
|
||||
vi.doUnmock("sharp");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import type { InferenceSession, Tensor } from "onnxruntime-node";
|
||||
import type sharpType from "sharp";
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import { ensureModel, selectProviders, type Device, type ModelId } from "./manager.js";
|
||||
|
||||
const INPUT_SIZE = 320;
|
||||
@@ -52,9 +53,8 @@ export interface CreateSessionOptions {
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
// onnxruntime-node and sharp are optional native modules — their platform
|
||||
// binaries don't install everywhere. Surface an actionable error instead of a
|
||||
// raw "Cannot find module" when one can't load.
|
||||
// sharp is an optional native module — its platform binary doesn't install
|
||||
// everywhere. Surface an actionable error instead of a raw "Cannot find module".
|
||||
async function loadNative<T>(name: string, load: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await load();
|
||||
@@ -67,11 +67,11 @@ async function loadNative<T>(name: string, load: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
export async function createSession(options: CreateSessionOptions = {}): Promise<Session> {
|
||||
const ort = (await loadNative(
|
||||
"onnxruntime-node",
|
||||
() => import("onnxruntime-node"),
|
||||
)) as unknown as OrtModule;
|
||||
const sharp = (await loadNative("sharp", () => import("sharp"))).default as Sharp;
|
||||
const ort = (await loadOptionalPackage(
|
||||
"onnxruntime-node",
|
||||
"background removal",
|
||||
)) as unknown as OrtModule;
|
||||
|
||||
const choice = selectProviders(options.device ?? "auto");
|
||||
const path = await ensureModel(options.model, { onProgress: options.onProgress });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveVisionPhaseCompletion,
|
||||
type VisionCaptionOutcome,
|
||||
} from "./contentExtractor.js";
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
|
||||
const { generateContentMock, clientOptions, sharpState } = vi.hoisted(() => ({
|
||||
generateContentMock: vi.fn(),
|
||||
@@ -60,13 +61,15 @@ vi.mock("sharp", () => {
|
||||
return { default: sharp };
|
||||
});
|
||||
|
||||
vi.mock("@google/genai", () => ({
|
||||
GoogleGenAI: class {
|
||||
models = { generateContent: generateContentMock };
|
||||
constructor(options: Record<string, unknown>) {
|
||||
clientOptions.push(options);
|
||||
}
|
||||
},
|
||||
vi.mock("../utils/optionalPackages.js", () => ({
|
||||
loadOptionalPackage: vi.fn(async () => ({
|
||||
GoogleGenAI: class {
|
||||
models = { generateContent: generateContentMock };
|
||||
constructor(options: Record<string, unknown>) {
|
||||
clientOptions.push(options);
|
||||
}
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// These tests exercise the OpenRouter provider path only — it makes a plain
|
||||
@@ -526,6 +529,20 @@ describe("captionImagesWithGemini — Vertex AI provider", () => {
|
||||
expect(clientOptions[0]).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("skips captioning with the loader's message when @google/genai cannot be installed", async () => {
|
||||
const dir = makeProjectWithImages();
|
||||
dirs.push(dir);
|
||||
vertexEnv();
|
||||
vi.mocked(loadOptionalPackage).mockRejectedValueOnce(new Error("install it yourself"));
|
||||
|
||||
const warnings: string[] = [];
|
||||
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
|
||||
|
||||
expect(captions).toEqual({});
|
||||
expect(warnings).toEqual(["Skipped vision captioning: install it yourself"]);
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours an explicit region", async () => {
|
||||
const dir = makeProjectWithImages();
|
||||
dirs.push(dir);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { basename, join } from "node:path";
|
||||
import type sharpType from "sharp";
|
||||
import type { CatalogedAsset } from "./assetCataloger.js";
|
||||
import type { DesignTokens } from "./types.js";
|
||||
import { loadOptionalPackage, type OptionalPackageModules } from "../utils/optionalPackages.js";
|
||||
|
||||
const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
@@ -359,7 +360,15 @@ export async function captionImagesWithGemini(
|
||||
}, timeoutMs);
|
||||
};
|
||||
} else {
|
||||
const { GoogleGenAI } = await import("@google/genai");
|
||||
let GoogleGenAI: OptionalPackageModules["@google/genai"]["GoogleGenAI"];
|
||||
try {
|
||||
({ GoogleGenAI } = await loadOptionalPackage("@google/genai", "vision captioning"));
|
||||
} catch (err) {
|
||||
warnings.push(`Skipped vision captioning: ${(err as Error).message}`);
|
||||
internalError = true;
|
||||
reportOutcome();
|
||||
return geminiCaptions;
|
||||
}
|
||||
let ai: InstanceType<typeof GoogleGenAI>;
|
||||
if (provider === "vertex") {
|
||||
// Re-narrow for TS; `useVertex` already guaranteed both are set.
|
||||
|
||||
@@ -137,10 +137,9 @@ vi.mock("../registry/localModel.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../registry/localEmbedder.js", () => ({
|
||||
// The native runtime is present in these tests. Left unmocked it answers
|
||||
// false under vitest, and every accepted offer returns at the runtime guard
|
||||
// before it can download, which looks like the download being skipped.
|
||||
localRuntimeAvailable: async () => state.runtimeAvailable,
|
||||
// Left unmocked this would run a real npm install under vitest.
|
||||
ensureLocalRuntime: async () =>
|
||||
state.runtimeAvailable ? { ok: true } : { ok: false, reason: "installing it failed" },
|
||||
}));
|
||||
|
||||
vi.mock("../registry/localSemantic.js", () => ({
|
||||
@@ -353,6 +352,16 @@ describe("catalog --json meaning search", () => {
|
||||
expect(envelope.warnings).toEqual(["on-device search did not run: model could not load"]);
|
||||
});
|
||||
|
||||
it("downloads nothing and says why when the runtime cannot be installed", async () => {
|
||||
state.modelStatus = "unavailable";
|
||||
state.runtimeAvailable = false;
|
||||
|
||||
const envelope = await runEnvelope({ query: "count up", "on-device": true, yes: true });
|
||||
|
||||
expect(state.downloads).toBe(0);
|
||||
expect(envelope.warnings).toEqual(["on-device search skipped: installing it failed"]);
|
||||
});
|
||||
|
||||
it("refreshes a changed vector revision under existing consent", async () => {
|
||||
state.cachedVectorRevision = "revision-previous";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
nonInteractiveConsentMessage,
|
||||
recordLocalModelConsent,
|
||||
} from "../registry/localModel.js";
|
||||
import { localRuntimeAvailable } from "../registry/localEmbedder.js";
|
||||
import { ensureLocalRuntime } from "../registry/localEmbedder.js";
|
||||
import {
|
||||
cachedLocalVectorRevision,
|
||||
fetchLocalVectors,
|
||||
@@ -90,13 +90,10 @@ async function prepareOnDeviceTier(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await localRuntimeAvailable())) {
|
||||
// Checked before downloading. Fetching 32 MB and then discovering the
|
||||
// runtime is missing wastes the bandwidth the consent was granted for.
|
||||
warn(
|
||||
"on-device search needs the native ONNX runtime, which a single-file build cannot load. " +
|
||||
"Install the CLI normally (npm i -g hyperframes) to use this tier.",
|
||||
);
|
||||
// Before the model download: fetching 32 MB and then finding the runtime missing wastes it.
|
||||
const runtime = await ensureLocalRuntime();
|
||||
if (!runtime.ok) {
|
||||
warn(`on-device search skipped: ${runtime.reason}`);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { optionalPackageDir } from "../utils/optionalPackages.js";
|
||||
import {
|
||||
buildDoctorReport,
|
||||
checkFramesCache,
|
||||
checkOptionalPackage,
|
||||
redactHome,
|
||||
parseToolVersion,
|
||||
type CheckOutcome,
|
||||
@@ -272,3 +277,34 @@ describe("checkFramesCache", () => {
|
||||
expect(result.detail).toContain("free space unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkOptionalPackage", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reports a package that has not been installed yet as ok, installing on first use", () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), "hf-doctor-optional-"));
|
||||
dirs.push(cacheDir);
|
||||
expect(checkOptionalPackage("onnxruntime-node", cacheDir)).toEqual({
|
||||
ok: true,
|
||||
detail: "Not installed (installs on first use)",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the installed version read from the cached package", () => {
|
||||
const cacheDir = mkdtempSync(join(tmpdir(), "hf-doctor-optional-"));
|
||||
dirs.push(cacheDir);
|
||||
const pkgDir = join(
|
||||
optionalPackageDir("@google/genai", cacheDir),
|
||||
"node_modules/@google/genai",
|
||||
);
|
||||
mkdirSync(pkgDir, { recursive: true });
|
||||
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version: "1.52.0" }));
|
||||
expect(checkOptionalPackage("@google/genai", cacheDir)).toEqual({
|
||||
ok: true,
|
||||
detail: "1.52.0 installed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,11 @@ import { KOKORO_MODULES, KOKORO_PIP, MUSICGEN_MODULES, MUSICGEN_PIP } from "../a
|
||||
import { hasPythonModules, describeRejectedPythonOverride } from "../tts/python.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
|
||||
import {
|
||||
OPTIONAL_PACKAGES,
|
||||
installedOptionalPackageVersion,
|
||||
type OptionalPackage,
|
||||
} from "../utils/optionalPackages.js";
|
||||
import {
|
||||
getSystemMeta,
|
||||
getShmSizeMb,
|
||||
@@ -281,6 +286,15 @@ function checkLocalMusic(): CheckResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** Not a failure when missing: the package installs itself the first time a feature needs it. */
|
||||
export function checkOptionalPackage(name: OptionalPackage, cacheDir?: string): CheckResult {
|
||||
const version = installedOptionalPackageVersion(name, cacheDir);
|
||||
return {
|
||||
ok: true,
|
||||
detail: version ? `${version} installed` : "Not installed (installs on first use)",
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckOutcome {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
@@ -354,6 +368,9 @@ export default defineCommand({
|
||||
checks.push({ name: "whisper-cpp", run: checkWhisper });
|
||||
checks.push({ name: "TTS (Kokoro)", run: checkLocalVoice });
|
||||
checks.push({ name: "BGM (MusicGen)", run: checkLocalMusic });
|
||||
for (const name of Object.keys(OPTIONAL_PACKAGES) as OptionalPackage[]) {
|
||||
checks.push({ name, run: () => checkOptionalPackage(name) });
|
||||
}
|
||||
|
||||
const outcomes: CheckOutcome[] = [];
|
||||
for (const check of checks) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { c } from "../ui/colors.js";
|
||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||
import { parseAngle, type Camera } from "./motionShotLayout.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
|
||||
@@ -834,7 +835,7 @@ export default defineCommand({
|
||||
console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`);
|
||||
} else if (paths.length > 0) {
|
||||
console.log(` ${c.dim("Describing frames with Gemini vision...")}`);
|
||||
const { GoogleGenAI } = await import("@google/genai");
|
||||
const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe");
|
||||
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
||||
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
|
||||
const customQuestion =
|
||||
@@ -915,7 +916,7 @@ export default defineCommand({
|
||||
}
|
||||
} catch (descErr) {
|
||||
const msg = normalizeErrorMessage(descErr);
|
||||
console.log(` ${c.dim(`--describe failed: ${msg.slice(0, 80)}`)}`);
|
||||
console.log(` ${c.dim(`--describe failed: ${msg}`)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import {
|
||||
LOCAL_MODEL_DIMENSIONS,
|
||||
QUERY_INSTRUCTION,
|
||||
@@ -28,26 +29,23 @@ export interface LocalEmbedder {
|
||||
/** Long inputs are truncated rather than rejected; the model has a 512 token limit. */
|
||||
const MAX_TOKENS = 512;
|
||||
|
||||
const FEATURE = "on-device search";
|
||||
|
||||
/**
|
||||
* Is the native ONNX runtime reachable in this install?
|
||||
*
|
||||
* It cannot be bundled: a single-file build leaves `onnxruntime-node` as an
|
||||
* unresolved import, so on-device search is unavailable there no matter what
|
||||
* else is on disk. Checking costs one dynamic import and saves a pointless
|
||||
* 32 MB download.
|
||||
* Installs the native ONNX runtime if it is missing and reports whether it can load.
|
||||
* Called before the model download so a missing runtime does not waste 32 MB of bandwidth.
|
||||
*/
|
||||
export async function localRuntimeAvailable(): Promise<boolean> {
|
||||
export async function ensureLocalRuntime(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
try {
|
||||
await import("onnxruntime-node");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
await loadOptionalPackage("onnxruntime-node", FEATURE);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLocalEmbedder(): Promise<LocalEmbedder> {
|
||||
// Imported lazily so the CLI does not pay for the ONNX runtime on every run.
|
||||
const ort = await import("onnxruntime-node");
|
||||
const ort = await loadOptionalPackage("onnxruntime-node", FEATURE);
|
||||
const config = configFromTokenizerJson(readFileSync(localTokenizerPath(), "utf-8"));
|
||||
const session = await ort.InferenceSession.create(localModelPath());
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNpxCommand } from "./npxCommand.js";
|
||||
import { buildNpmCommand, buildNpxCommand } from "./npxCommand.js";
|
||||
|
||||
describe("buildNpxCommand", () => {
|
||||
it.each([
|
||||
@@ -27,3 +27,16 @@ describe("buildNpxCommand", () => {
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("buildNpmCommand", () => {
|
||||
it.each([
|
||||
["linux", "npm", ["--version"]],
|
||||
["darwin", "npm", ["--version"]],
|
||||
["win32", "cmd.exe", ["/d", "/s", "/c", "npm.cmd", "--version"]],
|
||||
] as const)("builds the %s npm invocation", (platform, expectedCommand, expectedArgs) => {
|
||||
expect(buildNpmCommand(["--version"], platform)).toEqual({
|
||||
command: expectedCommand,
|
||||
args: expectedArgs,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,30 @@ export type NpxCommand = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
/** npm installs `name` as a `.cmd` shim on Windows; invoke it through cmd.exe
|
||||
* instead of relying on child_process to resolve or execute the shim, or on
|
||||
* `shell: true` with hand-rolled argument quoting. */
|
||||
function buildCmdShimCommand(
|
||||
name: string,
|
||||
args: readonly string[],
|
||||
platform: NodeJS.Platform,
|
||||
): NpxCommand {
|
||||
if (platform === "win32") {
|
||||
return { command: "cmd.exe", args: ["/d", "/s", "/c", `${name}.cmd`, ...args] };
|
||||
}
|
||||
return { command: name, args: [...args] };
|
||||
}
|
||||
|
||||
export function buildNpxCommand(
|
||||
args: readonly string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NpxCommand {
|
||||
if (platform === "win32") {
|
||||
// npm installs npx as a .cmd shim on Windows; invoke it through cmd.exe
|
||||
// instead of relying on child_process to resolve or execute the shim.
|
||||
return { command: "cmd.exe", args: ["/d", "/s", "/c", "npx.cmd", ...args] };
|
||||
}
|
||||
return buildCmdShimCommand("npx", args, platform);
|
||||
}
|
||||
|
||||
return { command: "npx", args: [...args] };
|
||||
export function buildNpmCommand(
|
||||
args: readonly string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NpxCommand {
|
||||
return buildCmdShimCommand("npm", args, platform);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OPTIONAL_PACKAGES,
|
||||
install,
|
||||
loadOptionalPackage,
|
||||
optionalPackageDir,
|
||||
type OptionalPackageDeps,
|
||||
} from "./optionalPackages.js";
|
||||
|
||||
function fakeDeps(overrides: Partial<OptionalPackageDeps> = {}) {
|
||||
const installed = new Map<string, unknown>();
|
||||
const log = vi.fn();
|
||||
const install = vi.fn(async (dir: string) => {
|
||||
installed.set(dir, { fake: "module" });
|
||||
});
|
||||
const deps: OptionalPackageDeps = {
|
||||
cacheDir: "/cache",
|
||||
loadInstalled: (dir) => installed.get(dir) ?? null,
|
||||
install,
|
||||
log,
|
||||
...overrides,
|
||||
};
|
||||
return { deps, install, log, installed };
|
||||
}
|
||||
|
||||
describe("loadOptionalPackage", () => {
|
||||
it("installs on first use with one plain line, then loads from the cache without installing again", async () => {
|
||||
const { deps, install, log } = fakeDeps();
|
||||
|
||||
const first = await loadOptionalPackage("onnxruntime-node", "background removal", deps);
|
||||
const second = await loadOptionalPackage("onnxruntime-node", "background removal", deps);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
optionalPackageDir("onnxruntime-node", "/cache"),
|
||||
"onnxruntime-node",
|
||||
OPTIONAL_PACKAGES["onnxruntime-node"],
|
||||
);
|
||||
expect(log.mock.calls).toEqual([["installing onnxruntime-node for background removal, once"]]);
|
||||
});
|
||||
|
||||
it("names the manual command when the install fails", async () => {
|
||||
const { deps } = fakeDeps({
|
||||
install: async () => {
|
||||
throw new Error(
|
||||
"npm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error at Foo.bar",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const failure = loadOptionalPackage("@google/genai", "--describe", deps);
|
||||
|
||||
await expect(failure).rejects.toThrow(
|
||||
/--describe needs @google\/genai, and installing it failed \(npm error code ENOTFOUND\)\. /,
|
||||
);
|
||||
await expect(failure).rejects.toThrow(
|
||||
`npm install @google/genai@${OPTIONAL_PACKAGES["@google/genai"]} --prefix "${optionalPackageDir("@google/genai", "/cache")}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("blames the network only for network failures", async () => {
|
||||
const failWith = (msg: string) =>
|
||||
loadOptionalPackage(
|
||||
"@google/genai",
|
||||
"--describe",
|
||||
fakeDeps({
|
||||
install: async () => {
|
||||
throw new Error(msg);
|
||||
},
|
||||
}).deps,
|
||||
);
|
||||
|
||||
await expect(failWith("npm error code ENOTFOUND")).rejects.toThrow(/Check your network/);
|
||||
const fsFailure = failWith("npm error code EACCES");
|
||||
await expect(fsFailure).rejects.not.toThrow(/network/);
|
||||
await expect(fsFailure).rejects.toThrow(/npm install @google\/genai@/);
|
||||
});
|
||||
|
||||
it("does not treat an install that leaves nothing loadable as installed", async () => {
|
||||
const { deps } = fakeDeps({ install: async () => {} });
|
||||
|
||||
await expect(loadOptionalPackage("onnxruntime-node", "on-device search", deps)).rejects.toThrow(
|
||||
/could not be loaded/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps each version in its own directory so a pin bump never reads a stale install", () => {
|
||||
expect(optionalPackageDir("@google/genai", "/cache")).toMatch(
|
||||
/[\\/]cache[\\/]@google__genai@\d+\.\d+\.\d+$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install", () => {
|
||||
const name = "@google/genai";
|
||||
const version = "1.0.0";
|
||||
|
||||
function setup() {
|
||||
const cache = mkdtempSync(join(tmpdir(), "hf-optional-"));
|
||||
const dir = join(cache, "pkg@1.0.0");
|
||||
const stubNpm = async (args: 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"), "{}");
|
||||
};
|
||||
return { cache, dir, stubNpm };
|
||||
}
|
||||
|
||||
it("gives each overlapping install in one process its own staging dir", async () => {
|
||||
const { cache, dir, stubNpm } = setup();
|
||||
try {
|
||||
await Promise.all([
|
||||
install(dir, name, version, stubNpm),
|
||||
install(dir, name, version, stubNpm),
|
||||
]);
|
||||
expect(existsSync(join(dir, "node_modules", name, "package.json"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves another live process's in-progress staging dir alone while it installs", async () => {
|
||||
const { cache, dir, stubNpm } = setup();
|
||||
const foreign = `${dir}.tmp-${process.ppid}-abcd1234`;
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "partial"), "downloading");
|
||||
try {
|
||||
await install(dir, name, version, stubNpm);
|
||||
expect(existsSync(join(foreign, "partial"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sweeps a staging dir whose pid is dead and keeps one whose pid is alive", async () => {
|
||||
const { cache, dir, stubNpm } = setup();
|
||||
const deadPid = spawnSync(process.execPath, ["-e", ""]).pid as number;
|
||||
const dead = `${dir}.tmp-${deadPid}-abcd1234`;
|
||||
const deadOldFormat = `${dir}.tmp-${deadPid}`;
|
||||
const alive = `${dir}.tmp-${process.ppid}-abcd1234`;
|
||||
mkdirSync(dead, { recursive: true });
|
||||
mkdirSync(deadOldFormat, { recursive: true });
|
||||
mkdirSync(alive, { recursive: true });
|
||||
try {
|
||||
await install(dir, name, version, stubNpm);
|
||||
expect(existsSync(dead)).toBe(false);
|
||||
expect(existsSync(deadOldFormat)).toBe(false);
|
||||
expect(existsSync(alive)).toBe(true);
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { buildNpmCommand } from "./npxCommand.js";
|
||||
|
||||
/** Module type of each optional package; the keys are the only names the loader accepts. */
|
||||
export interface OptionalPackageModules {
|
||||
"onnxruntime-node": typeof import("onnxruntime-node");
|
||||
"@google/genai": typeof import("@google/genai");
|
||||
}
|
||||
|
||||
export type OptionalPackage = keyof OptionalPackageModules;
|
||||
|
||||
/** Installed on first use instead of with the CLI: their dependency trees carry deprecated packages. */
|
||||
export const OPTIONAL_PACKAGES = {
|
||||
"onnxruntime-node": "1.21.1",
|
||||
"@google/genai": "1.52.0",
|
||||
} as const satisfies Record<OptionalPackage, string>;
|
||||
|
||||
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "optional");
|
||||
|
||||
export interface OptionalPackageDeps {
|
||||
cacheDir: string;
|
||||
/** The package's exports when already installed in `dir`, else null. */
|
||||
loadInstalled(dir: string, name: string): unknown | null;
|
||||
/** Install `name@version` into `dir`; rejects with npm's output on failure. */
|
||||
install(dir: string, name: string, version: string): Promise<void>;
|
||||
log(line: string): void;
|
||||
}
|
||||
|
||||
/** One directory per package and version, so a version bump never reads a stale install. */
|
||||
export function optionalPackageDir(name: OptionalPackage, cacheDir = CACHE_DIR): string {
|
||||
return join(cacheDir, `${name.replace("/", "__")}@${OPTIONAL_PACKAGES[name]}`);
|
||||
}
|
||||
|
||||
/** Installed version, or null. Reads the manifest only, so it never loads a native binding. */
|
||||
export function installedOptionalPackageVersion(
|
||||
name: OptionalPackage,
|
||||
cacheDir = CACHE_DIR,
|
||||
): string | null {
|
||||
const dir = optionalPackageDir(name, cacheDir);
|
||||
if (!isInstalled(dir, name)) return null;
|
||||
return (JSON.parse(readFileSync(manifestPath(dir, name), "utf-8")) as { version: string })
|
||||
.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional package, installing it once on first use. No prompt: agents run headless.
|
||||
* Throws an error that names the manual command when the install cannot complete.
|
||||
*/
|
||||
export async function loadOptionalPackage<N extends OptionalPackage>(
|
||||
name: N,
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
): Promise<OptionalPackageModules[N]> {
|
||||
const dir = optionalPackageDir(name, deps.cacheDir);
|
||||
const installed = deps.loadInstalled(dir, name);
|
||||
if (installed !== null) return installed as OptionalPackageModules[N];
|
||||
|
||||
const version = OPTIONAL_PACKAGES[name];
|
||||
deps.log(`installing ${name} for ${feature}, once`);
|
||||
try {
|
||||
await deps.install(dir, name, version);
|
||||
} catch (err) {
|
||||
const output = (err as Error).message.trim();
|
||||
const advice = NETWORK_FAILURE.test(output)
|
||||
? "Check your network connection, then retry"
|
||||
: "Retry";
|
||||
throw new Error(
|
||||
`${feature} needs ${name}, and installing it failed (${output.split("\n")[0]}). ` +
|
||||
`${advice}, or install it yourself: npm install ${name}@${version} --prefix "${dir}"`,
|
||||
);
|
||||
}
|
||||
const loaded = deps.loadInstalled(dir, name);
|
||||
if (loaded === null) {
|
||||
throw new Error(`${name}@${version} installed into ${dir} but could not be loaded.`);
|
||||
}
|
||||
return loaded as OptionalPackageModules[N];
|
||||
}
|
||||
|
||||
const NETWORK_FAILURE = /ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|ECONNREFUSED|network/i;
|
||||
|
||||
function manifestPath(dir: string, name: string): string {
|
||||
return join(dir, "node_modules", name, "package.json");
|
||||
}
|
||||
|
||||
function isInstalled(dir: string, name: string): boolean {
|
||||
return existsSync(manifestPath(dir, name));
|
||||
}
|
||||
|
||||
function loadInstalled(dir: string, name: string): unknown | null {
|
||||
if (!isInstalled(dir, name)) return null;
|
||||
return createRequire(join(dir, "package.json"))(name);
|
||||
}
|
||||
|
||||
function runNpm(args: string[]): Promise<void> {
|
||||
const npm = buildNpmCommand(args);
|
||||
const child = spawn(npm.command, npm.args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => (output += chunk));
|
||||
child.stderr.on("data", (chunk) => (output += chunk));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(output || `npm exited with code ${code}`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes staging dirs whose owning pid is dead (crash or kill mid-install); never a live one. */
|
||||
function sweepStaleStaging(dir: string): void {
|
||||
const prefix = `${basename(dir)}.tmp-`;
|
||||
const parent = dirname(dir);
|
||||
if (!existsSync(parent)) return;
|
||||
for (const entry of readdirSync(parent)) {
|
||||
if (!entry.startsWith(prefix)) continue;
|
||||
const pid = /^(\d+)(?:-|$)/.exec(entry.slice(prefix.length))?.[1];
|
||||
if (pid === undefined || isProcessAlive(Number(pid))) continue;
|
||||
const stale = join(parent, entry);
|
||||
try {
|
||||
rmSync(stale, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Best effort: a locked stale dir must not block a viable install, but it stays visible.
|
||||
console.error(`could not remove stale install dir ${stale}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs into a sibling staging dir, then renames, so `dir` only ever holds a complete install.
|
||||
* No cross-process lock: two first runs both download and the loser discards its copy.
|
||||
*/
|
||||
export async function install(
|
||||
dir: string,
|
||||
name: string,
|
||||
version: string,
|
||||
run: (args: string[]) => Promise<void> = runNpm,
|
||||
): Promise<void> {
|
||||
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",
|
||||
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);
|
||||
} catch (err) {
|
||||
if (!isInstalled(dir, name)) throw err;
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDeps: OptionalPackageDeps = {
|
||||
cacheDir: CACHE_DIR,
|
||||
loadInstalled,
|
||||
install,
|
||||
log: (line) => console.error(line),
|
||||
};
|
||||
Reference in New Issue
Block a user