Skip to content

Commit 7a4dfc9

Browse files
authored
Merge pull request #110 from optave/feat/interactive-transformers-install
feat(embedder): interactive install prompt for @huggingface/transformers
2 parents 6e7bb78 + cc7c3e1 commit 7a4dfc9

3 files changed

Lines changed: 187 additions & 6 deletions

File tree

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,15 @@
5959
"commander": "^14.0.3",
6060
"web-tree-sitter": "^0.26.5"
6161
},
62+
"peerDependencies": {
63+
"@huggingface/transformers": "^3.8.1"
64+
},
65+
"peerDependenciesMeta": {
66+
"@huggingface/transformers": {
67+
"optional": true
68+
}
69+
},
6270
"optionalDependencies": {
63-
"@huggingface/transformers": "^3.8.1",
6471
"@modelcontextprotocol/sdk": "^1.0.0",
6572
"@optave/codegraph-darwin-arm64": "2.3.0",
6673
"@optave/codegraph-darwin-x64": "2.3.0",

src/embedder.js

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { execFileSync } from 'node:child_process';
12
import fs from 'node:fs';
23
import path from 'node:path';
4+
import { createInterface } from 'node:readline';
35
import Database from 'better-sqlite3';
46
import { findDbPath, openReadonlyOrFail } from './db.js';
57
import { warn } from './logger.js';
@@ -222,18 +224,52 @@ function buildSourceText(node, file, lines) {
222224
return `${node.kind} ${node.name} (${readable}) in ${file}\n${context}`;
223225
}
224226

227+
/**
228+
* Prompt the user to install a missing package interactively.
229+
* Returns true if the package was installed, false otherwise.
230+
* Skips the prompt entirely in non-TTY environments (CI, piped stdin).
231+
*/
232+
function promptInstall(packageName) {
233+
if (!process.stdin.isTTY) return Promise.resolve(false);
234+
235+
return new Promise((resolve) => {
236+
const rl = createInterface({ input: process.stdin, output: process.stderr });
237+
rl.question(`Semantic search requires ${packageName}. Install it now? [y/N] `, (answer) => {
238+
rl.close();
239+
if (answer.trim().toLowerCase() !== 'y') return resolve(false);
240+
try {
241+
execFileSync('npm', ['install', packageName], {
242+
stdio: 'inherit',
243+
timeout: 300_000,
244+
});
245+
resolve(true);
246+
} catch {
247+
resolve(false);
248+
}
249+
});
250+
});
251+
}
252+
225253
/**
226254
* Lazy-load @huggingface/transformers.
227-
* This is an optional dependency — gives a clear error if not installed.
255+
* If the package is missing, prompts the user to install it interactively.
256+
* In non-TTY environments, prints an error and exits.
228257
*/
229258
async function loadTransformers() {
230259
try {
231260
return await import('@huggingface/transformers');
232261
} catch {
233-
console.error(
234-
'Semantic search requires @huggingface/transformers.\n' +
235-
'Install it with: npm install @huggingface/transformers',
236-
);
262+
const pkg = '@huggingface/transformers';
263+
const installed = await promptInstall(pkg);
264+
if (installed) {
265+
try {
266+
return await import(pkg);
267+
} catch {
268+
console.error(`\n${pkg} was installed but failed to load. Please check your environment.`);
269+
process.exit(1);
270+
}
271+
}
272+
console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`);
237273
process.exit(1);
238274
}
239275
}

tests/unit/prompt-install.test.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Unit tests for the interactive install prompt in src/embedder.js.
3+
*
4+
* Tests the promptInstall() + loadTransformers() flow when
5+
* @huggingface/transformers is missing.
6+
*
7+
* Each test uses vi.resetModules() + vi.doMock() + dynamic import()
8+
* so every test gets a fresh embedder module with its own mocks.
9+
*/
10+
11+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
12+
13+
describe('loadTransformers install prompt', () => {
14+
let exitSpy;
15+
let errorSpy;
16+
let logSpy;
17+
let origTTY;
18+
19+
beforeEach(() => {
20+
vi.resetModules();
21+
origTTY = process.stdin.isTTY;
22+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
23+
throw new Error(`process.exit(${code})`);
24+
});
25+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
26+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
27+
});
28+
29+
afterEach(() => {
30+
process.stdin.isTTY = origTTY;
31+
exitSpy.mockRestore();
32+
errorSpy.mockRestore();
33+
logSpy.mockRestore();
34+
vi.restoreAllMocks();
35+
});
36+
37+
test('non-TTY: prints error and exits without prompting', async () => {
38+
process.stdin.isTTY = undefined;
39+
40+
const rlFactory = vi.fn();
41+
vi.doMock('node:readline', () => ({ createInterface: rlFactory }));
42+
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
43+
vi.doMock('@huggingface/transformers', () => {
44+
throw new Error('Cannot find package');
45+
});
46+
47+
const { embed } = await import('../../src/embedder.js');
48+
49+
await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
50+
expect(errorSpy).toHaveBeenCalledWith(
51+
expect.stringContaining('Semantic search requires @huggingface/transformers'),
52+
);
53+
// readline should NOT have been called — no prompt in non-TTY
54+
expect(rlFactory).not.toHaveBeenCalled();
55+
});
56+
57+
test('TTY + user declines: prints error and exits', async () => {
58+
process.stdin.isTTY = true;
59+
60+
vi.doMock('node:readline', () => ({
61+
createInterface: () => ({
62+
question: (_prompt, cb) => cb('n'),
63+
close: vi.fn(),
64+
}),
65+
}));
66+
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
67+
vi.doMock('@huggingface/transformers', () => {
68+
throw new Error('Cannot find package');
69+
});
70+
71+
const { embed } = await import('../../src/embedder.js');
72+
73+
await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
74+
expect(errorSpy).toHaveBeenCalledWith(
75+
expect.stringContaining('Semantic search requires @huggingface/transformers'),
76+
);
77+
});
78+
79+
test('TTY + user accepts but npm install fails: prints error and exits', async () => {
80+
process.stdin.isTTY = true;
81+
82+
const execMock = vi.fn(() => {
83+
throw new Error('npm ERR!');
84+
});
85+
vi.doMock('node:readline', () => ({
86+
createInterface: () => ({
87+
question: (_prompt, cb) => cb('y'),
88+
close: vi.fn(),
89+
}),
90+
}));
91+
vi.doMock('node:child_process', () => ({ execFileSync: execMock }));
92+
vi.doMock('@huggingface/transformers', () => {
93+
throw new Error('Cannot find package');
94+
});
95+
96+
const { embed } = await import('../../src/embedder.js');
97+
98+
await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
99+
expect(execMock).toHaveBeenCalledWith(
100+
'npm',
101+
['install', '@huggingface/transformers'],
102+
expect.objectContaining({ stdio: 'inherit', timeout: 300_000 }),
103+
);
104+
expect(errorSpy).toHaveBeenCalledWith(
105+
expect.stringContaining('Semantic search requires @huggingface/transformers'),
106+
);
107+
});
108+
109+
test('TTY + install succeeds: retries import and loads module', async () => {
110+
process.stdin.isTTY = true;
111+
112+
let importCount = 0;
113+
vi.doMock('node:readline', () => ({
114+
createInterface: () => ({
115+
question: (_prompt, cb) => cb('y'),
116+
close: vi.fn(),
117+
}),
118+
}));
119+
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
120+
vi.doMock('@huggingface/transformers', () => {
121+
importCount++;
122+
if (importCount <= 1) throw new Error('Cannot find package');
123+
return {
124+
pipeline: async () => async (batch) => ({
125+
data: new Float32Array(384 * batch.length),
126+
}),
127+
cos_sim: () => 0,
128+
};
129+
});
130+
131+
const { embed } = await import('../../src/embedder.js');
132+
133+
const result = await embed(['test text'], 'minilm');
134+
expect(result.vectors).toHaveLength(1);
135+
expect(result.dim).toBe(384);
136+
expect(exitSpy).not.toHaveBeenCalled();
137+
});
138+
});

0 commit comments

Comments
 (0)