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); } } 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(); + }); +});