From 8e717b274400eccfbe99b302658362307700213a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 22:10:58 -0700 Subject: [PATCH 1/2] feat(embedder): interactive install prompt for @huggingface/transformers Move @huggingface/transformers from optionalDependencies to peerDependencies (optional) so npm no longer auto-installs it. When a user runs embed/search without the package, prompt them interactively to install it. Non-TTY environments skip the prompt and print the existing error message. Impact: 2 functions changed, 6 affected --- package.json | 9 ++++++++- src/embedder.js | 46 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ea801e99..5dd9874f 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,15 @@ "commander": "^14.0.3", "web-tree-sitter": "^0.26.5" }, + "peerDependencies": { + "@huggingface/transformers": "^3.8.1" + }, + "peerDependenciesMeta": { + "@huggingface/transformers": { + "optional": true + } + }, "optionalDependencies": { - "@huggingface/transformers": "^3.8.1", "@modelcontextprotocol/sdk": "^1.0.0", "@optave/codegraph-darwin-arm64": "2.3.0", "@optave/codegraph-darwin-x64": "2.3.0", diff --git a/src/embedder.js b/src/embedder.js index 4aba1e7d..3bc8d917 100644 --- a/src/embedder.js +++ b/src/embedder.js @@ -1,5 +1,7 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { createInterface } from 'node:readline'; import Database from 'better-sqlite3'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { warn } from './logger.js'; @@ -222,18 +224,52 @@ function buildSourceText(node, file, lines) { return `${node.kind} ${node.name} (${readable}) in ${file}\n${context}`; } +/** + * Prompt the user to install a missing package interactively. + * Returns true if the package was installed, false otherwise. + * Skips the prompt entirely in non-TTY environments (CI, piped stdin). + */ +function promptInstall(packageName) { + if (!process.stdin.isTTY) return Promise.resolve(false); + + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + rl.question(`Semantic search requires ${packageName}. Install it now? [y/N] `, (answer) => { + rl.close(); + if (answer.trim().toLowerCase() !== 'y') return resolve(false); + try { + execFileSync('npm', ['install', packageName], { + stdio: 'inherit', + timeout: 300_000, + }); + resolve(true); + } catch { + resolve(false); + } + }); + }); +} + /** * Lazy-load @huggingface/transformers. - * This is an optional dependency — gives a clear error if not installed. + * If the package is missing, prompts the user to install it interactively. + * In non-TTY environments, prints an error and exits. */ async function loadTransformers() { try { return await import('@huggingface/transformers'); } catch { - console.error( - 'Semantic search requires @huggingface/transformers.\n' + - 'Install it with: npm install @huggingface/transformers', - ); + const pkg = '@huggingface/transformers'; + const installed = await promptInstall(pkg); + if (installed) { + try { + return await import(pkg); + } catch { + console.error(`\n${pkg} was installed but failed to load. Please check your environment.`); + process.exit(1); + } + } + console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`); process.exit(1); } } From cc7c3e1ea8640c35c40b05afe7ae449496862282 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 22:16:52 -0700 Subject: [PATCH 2/2] test: add unit tests for interactive install prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the four promptInstall/loadTransformers paths: - non-TTY skips prompt and exits with error - TTY + user declines → exits with error - TTY + user accepts but npm install fails → exits with error - TTY + install succeeds → retries import and loads module --- tests/unit/prompt-install.test.js | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/unit/prompt-install.test.js diff --git a/tests/unit/prompt-install.test.js b/tests/unit/prompt-install.test.js new file mode 100644 index 00000000..d7583508 --- /dev/null +++ b/tests/unit/prompt-install.test.js @@ -0,0 +1,138 @@ +/** + * Unit tests for the interactive install prompt in src/embedder.js. + * + * Tests the promptInstall() + loadTransformers() flow when + * @huggingface/transformers is missing. + * + * Each test uses vi.resetModules() + vi.doMock() + dynamic import() + * so every test gets a fresh embedder module with its own mocks. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('loadTransformers install prompt', () => { + let exitSpy; + let errorSpy; + let logSpy; + let origTTY; + + beforeEach(() => { + vi.resetModules(); + origTTY = process.stdin.isTTY; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.stdin.isTTY = origTTY; + exitSpy.mockRestore(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + test('non-TTY: prints error and exits without prompting', async () => { + process.stdin.isTTY = undefined; + + const rlFactory = vi.fn(); + vi.doMock('node:readline', () => ({ createInterface: rlFactory })); + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() })); + vi.doMock('@huggingface/transformers', () => { + throw new Error('Cannot find package'); + }); + + const { embed } = await import('../../src/embedder.js'); + + await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Semantic search requires @huggingface/transformers'), + ); + // readline should NOT have been called — no prompt in non-TTY + expect(rlFactory).not.toHaveBeenCalled(); + }); + + test('TTY + user declines: prints error and exits', async () => { + process.stdin.isTTY = true; + + vi.doMock('node:readline', () => ({ + createInterface: () => ({ + question: (_prompt, cb) => cb('n'), + close: vi.fn(), + }), + })); + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() })); + vi.doMock('@huggingface/transformers', () => { + throw new Error('Cannot find package'); + }); + + const { embed } = await import('../../src/embedder.js'); + + await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Semantic search requires @huggingface/transformers'), + ); + }); + + test('TTY + user accepts but npm install fails: prints error and exits', async () => { + process.stdin.isTTY = true; + + const execMock = vi.fn(() => { + throw new Error('npm ERR!'); + }); + vi.doMock('node:readline', () => ({ + createInterface: () => ({ + question: (_prompt, cb) => cb('y'), + close: vi.fn(), + }), + })); + vi.doMock('node:child_process', () => ({ execFileSync: execMock })); + vi.doMock('@huggingface/transformers', () => { + throw new Error('Cannot find package'); + }); + + const { embed } = await import('../../src/embedder.js'); + + await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)'); + expect(execMock).toHaveBeenCalledWith( + 'npm', + ['install', '@huggingface/transformers'], + expect.objectContaining({ stdio: 'inherit', timeout: 300_000 }), + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Semantic search requires @huggingface/transformers'), + ); + }); + + test('TTY + install succeeds: retries import and loads module', async () => { + process.stdin.isTTY = true; + + let importCount = 0; + vi.doMock('node:readline', () => ({ + createInterface: () => ({ + question: (_prompt, cb) => cb('y'), + close: vi.fn(), + }), + })); + vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() })); + vi.doMock('@huggingface/transformers', () => { + importCount++; + if (importCount <= 1) throw new Error('Cannot find package'); + return { + pipeline: async () => async (batch) => ({ + data: new Float32Array(384 * batch.length), + }), + cos_sim: () => 0, + }; + }); + + const { embed } = await import('../../src/embedder.js'); + + const result = await embed(['test text'], 'minilm'); + expect(result.vectors).toHaveLength(1); + expect(result.dim).toBe(384); + expect(exitSpy).not.toHaveBeenCalled(); + }); +});