diff --git a/package.json b/package.json index df9c1008..4e1644c1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "files": [ "bundle", + "embeddings", "codex/bundle", "codex/skills", "cursor/bundle", diff --git a/src/cli/embeddings.ts b/src/cli/embeddings.ts index 9f1ed3f6..61334fce 100644 --- a/src/cli/embeddings.ts +++ b/src/cli/embeddings.ts @@ -102,6 +102,47 @@ function isSymbolicLink(path: string): boolean { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } } +type ExecFileSyncFn = typeof execFileSync; + +/** + * Run `npm install` for the shared embedding deps, falling back to + * `--ignore-scripts` if the first attempt fails (e.g. sharp has no prebuilt + * binary for Node v26 on macOS and requires node-gyp). After a successful + * fallback, explicitly re-runs onnxruntime-node's postinstall so its binary + * is still downloaded. Exported for unit testing. + */ +export function _installWithFallback( + sharedDir: string, + sharedNodeModules: string, + exec: ExecFileSyncFn = execFileSync, +): void { + const npmArgs = ["install", "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund"]; + try { + exec("npm", npmArgs, { cwd: sharedDir, stdio: "inherit" }); + } catch (err) { + warn(` Embeddings install failed (${err instanceof Error ? err.message : String(err)}); retrying without install scripts`); + warn(` Embeddings (sharp image support unavailable — text embeddings unaffected)`); + try { + exec("npm", [...npmArgs, "--ignore-scripts"], { cwd: sharedDir, stdio: "inherit" }); + } catch (retryErr) { + throw new Error( + `Embedding deps install failed even without scripts: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}` + ); + } + const onnxScript = join(sharedNodeModules, "onnxruntime-node", "script", "install.js"); + if (existsSync(onnxScript)) { + try { + exec("node", [onnxScript], { + cwd: join(sharedNodeModules, "onnxruntime-node"), + stdio: "inherit", + }); + } catch (onnxErr) { + warn(` Embeddings onnxruntime-node install script failed — embeddings may not work: ${onnxErr instanceof Error ? onnxErr.message : String(onnxErr)}`); + } + } + } +} + function ensureSharedDeps(): void { if (!isSharedDepsInstalled()) { log(` Embeddings installing ${TRANSFORMERS_PKG}@${TRANSFORMERS_RANGE} into ${SHARED_DIR}`); @@ -113,10 +154,7 @@ function ensureSharedDeps(): void { private: true, dependencies: { [TRANSFORMERS_PKG]: TRANSFORMERS_RANGE }, }); - execFileSync("npm", ["install", "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund"], { - cwd: SHARED_DIR, - stdio: "inherit", - }); + _installWithFallback(SHARED_DIR, SHARED_NODE_MODULES); } else { log(` Embeddings shared deps already present at ${SHARED_DIR}`); } diff --git a/tests/cli/cli-embeddings.test.ts b/tests/cli/cli-embeddings.test.ts index afa66c06..721c98c3 100644 --- a/tests/cli/cli-embeddings.test.ts +++ b/tests/cli/cli-embeddings.test.ts @@ -15,6 +15,7 @@ import { TRANSFORMERS_PKG, uninstallEmbeddings, _linkAgentForTesting, + _installWithFallback, } from "../../src/cli/embeddings.js"; import { _resetUserConfigForTesting, _setConfigPathForTesting, getEmbeddingsEnabled } from "../../src/user-config.js"; @@ -327,3 +328,81 @@ describe("uninstallEmbeddings — config flag side effect", () => { expect(getEmbeddingsEnabled()).toBe(false); }); }); + +// ── _installWithFallback ────────────────────────────────────────────────── + +describe("_installWithFallback — npm install + fallback logic", () => { + it("happy path: calls npm install once with standard args and never retries", () => { + const exec = vi.fn(); + _installWithFallback("/fake/shared", "/fake/shared/node_modules", exec); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith( + "npm", + ["install", "--omit=dev", "--no-package-lock", "--no-audit", "--no-fund"], + expect.objectContaining({ cwd: "/fake/shared" }), + ); + }); + + it("retry path: first call fails → retries with --ignore-scripts", () => { + let call = 0; + const exec = vi.fn(() => { + if (call++ === 0) throw new Error("sharp build failed"); + }) as any; + _installWithFallback("/fake/shared", "/fake/nm", exec); + expect(exec).toHaveBeenCalledTimes(2); + const [, secondArgs] = exec.mock.calls[1] as [string, string[]]; + expect(secondArgs).toContain("--ignore-scripts"); + }); + + it("retry path: runs onnxruntime install script when it exists after --ignore-scripts", () => { + const sharedNm = join(tmpHome, "node_modules"); + const onnxDir = join(sharedNm, "onnxruntime-node", "script"); + mkdirSync(onnxDir, { recursive: true }); + writeFileSync(join(onnxDir, "install.js"), "// stub"); + + let call = 0; + const exec = vi.fn(() => { + if (call++ === 0) throw new Error("sharp failed"); + }) as any; + + _installWithFallback(tmpHome, sharedNm, exec); + + // npm install (fail) + npm install --ignore-scripts + node onnxScript + expect(exec).toHaveBeenCalledTimes(3); + const [thirdCmd, thirdArgs] = exec.mock.calls[2] as [string, string[]]; + expect(thirdCmd).toBe("node"); + expect(thirdArgs[0]).toContain("onnxruntime-node"); + expect(thirdArgs[0]).toContain("install.js"); + }); + + it("retry path: onnxruntime script failure warns but does not throw", () => { + const sharedNm = join(tmpHome, "node_modules"); + const onnxDir = join(sharedNm, "onnxruntime-node", "script"); + mkdirSync(onnxDir, { recursive: true }); + writeFileSync(join(onnxDir, "install.js"), "// stub"); + + let call = 0; + const exec = vi.fn(() => { + if (call++ !== 1) throw new Error("fail"); // npm fail, onnx fail; retry succeeds + }) as any; + + expect(() => _installWithFallback(tmpHome, sharedNm, exec)).not.toThrow(); + }); + + it("hard-fail path: throws when retry with --ignore-scripts also fails", () => { + const exec = vi.fn(() => { throw new Error("disk full"); }) as any; + expect(() => _installWithFallback("/fake/shared", "/fake/nm", exec)) + .toThrow("Embedding deps install failed even without scripts"); + }); + + it("no onnxruntime script: skips the third exec call silently", () => { + // onnxruntime dir doesn't exist — existsSync returns false + let call = 0; + const exec = vi.fn(() => { + if (call++ === 0) throw new Error("sharp failed"); + }) as any; + _installWithFallback("/fake/shared", join(tmpHome, "absent-nm"), exec); + // only 2 calls: first npm (fail) + retry npm --ignore-scripts + expect(exec).toHaveBeenCalledTimes(2); + }); +});