Skip to content

Commit 5be6dce

Browse files
committed
feat: add binary resolver for agent-analyzer
New lib/binary/ module with lazy download from GitHub releases. Handles 5 platform targets, tar.gz/zip extraction, version checking, and auto-upgrade. Zero external npm dependencies - uses only Node.js built-ins. Exports ensureBinary, runAnalyzer, and related utilities.
1 parent d263d78 commit 5be6dce

File tree

3 files changed

+416
-0
lines changed

3 files changed

+416
-0
lines changed

lib/binary/index.js

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
'use strict';
2+
3+
/**
4+
* Binary resolver for the agent-analyzer Rust binary.
5+
*
6+
* Handles lazy downloading and execution. Since Claude Code plugins have no
7+
* postinstall hooks, the binary is downloaded at runtime on first use.
8+
*
9+
* @module lib/binary
10+
*/
11+
12+
const fs = require('fs');
13+
const path = require('path');
14+
const os = require('os');
15+
const https = require('https');
16+
const cp = require('child_process');
17+
const { promisify } = require('util');
18+
19+
const execFileAsync = promisify(cp.execFile);
20+
21+
const { ANALYZER_MIN_VERSION, BINARY_NAME, GITHUB_REPO } = require('./version');
22+
23+
const PLATFORM_MAP = {
24+
'darwin-arm64': 'aarch64-apple-darwin',
25+
'darwin-x64': 'x86_64-apple-darwin',
26+
'linux-x64': 'x86_64-unknown-linux-gnu',
27+
'linux-arm64': 'aarch64-unknown-linux-gnu',
28+
'win32-x64': 'x86_64-pc-windows-msvc'
29+
};
30+
31+
// ---------------------------------------------------------------------------
32+
// Path helpers
33+
// ---------------------------------------------------------------------------
34+
35+
/**
36+
* Returns the expected path to the agent-analyzer binary.
37+
* @returns {string}
38+
*/
39+
function getBinaryPath() {
40+
const ext = process.platform === 'win32' ? '.exe' : '';
41+
return path.join(os.homedir(), '.agent-sh', 'bin', BINARY_NAME + ext);
42+
}
43+
44+
/**
45+
* Returns the Rust target triple for the current platform.
46+
* @returns {string|null}
47+
*/
48+
function getPlatformKey() {
49+
const key = process.platform + '-' + process.arch;
50+
return PLATFORM_MAP[key] || null;
51+
}
52+
53+
// ---------------------------------------------------------------------------
54+
// Version helpers
55+
// ---------------------------------------------------------------------------
56+
57+
/**
58+
* Compare a version string against a minimum requirement.
59+
* @param {string} version
60+
* @param {string} minVersion
61+
* @returns {boolean}
62+
*/
63+
function meetsMinimumVersion(version, minVersion) {
64+
if (!version) return false;
65+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
66+
if (!match) return false;
67+
const parts = match.slice(1).map(Number);
68+
const req = minVersion.split('.').map(Number);
69+
if (parts[0] > req[0]) return true;
70+
if (parts[0] < req[0]) return false;
71+
if (parts[1] > req[1]) return true;
72+
if (parts[1] < req[1]) return false;
73+
return parts[2] >= req[2];
74+
}
75+
76+
/**
77+
* Run the binary with --version and return the version string, or null on failure.
78+
* @returns {string|null}
79+
*/
80+
function getVersion() {
81+
const binPath = getBinaryPath();
82+
if (!fs.existsSync(binPath)) return null;
83+
try {
84+
const out = cp.execFileSync(binPath, ['--version'], {
85+
timeout: 5000,
86+
encoding: 'utf8',
87+
stdio: ['pipe', 'pipe', 'pipe'],
88+
windowsHide: true
89+
});
90+
const match = out.trim().match(/(\d+\.\d+\.\d+)/);
91+
return match ? match[1] : out.trim();
92+
} catch (e) {
93+
return null;
94+
}
95+
}
96+
97+
// ---------------------------------------------------------------------------
98+
// Availability checks
99+
// ---------------------------------------------------------------------------
100+
101+
/**
102+
* Sync check: returns true if the binary exists and meets the minimum version.
103+
* Does NOT download.
104+
* @returns {boolean}
105+
*/
106+
function isAvailable() {
107+
const binPath = getBinaryPath();
108+
if (!fs.existsSync(binPath)) return false;
109+
const ver = getVersion();
110+
return meetsMinimumVersion(ver, ANALYZER_MIN_VERSION);
111+
}
112+
113+
/**
114+
* Async check: returns true if the binary exists and meets the minimum version.
115+
* Does NOT download.
116+
* @returns {Promise<boolean>}
117+
*/
118+
async function isAvailableAsync() {
119+
return isAvailable();
120+
}
121+
122+
// ---------------------------------------------------------------------------
123+
// Download
124+
// ---------------------------------------------------------------------------
125+
126+
/**
127+
* Build the GitHub release download URL.
128+
* @param {string} ver
129+
* @param {string} platformKey
130+
* @returns {string}
131+
*/
132+
function buildDownloadUrl(ver, platformKey) {
133+
const ext = process.platform === 'win32' ? '.zip' : '.tar.gz';
134+
return 'https://github.com/' + GITHUB_REPO + '/releases/download/v' + ver + '/' + BINARY_NAME + '-' + platformKey + ext;
135+
}
136+
137+
/**
138+
* Download a URL to a Buffer, following up to 5 redirects.
139+
* Supports GITHUB_TOKEN / GH_TOKEN for auth.
140+
* @param {string} url
141+
* @returns {Promise<Buffer>}
142+
*/
143+
function downloadToBuffer(url) {
144+
return new Promise(function(resolve, reject) {
145+
const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
146+
147+
function request(reqUrl, redirectCount) {
148+
if (redirectCount > 5) {
149+
reject(new Error('Too many redirects fetching from ' + url));
150+
return;
151+
}
152+
const headers = {
153+
'User-Agent': 'agent-core/binary-resolver',
154+
'Accept': 'application/octet-stream'
155+
};
156+
if (ghToken) headers['Authorization'] = 'Bearer ' + ghToken;
157+
158+
https.get(reqUrl, { headers: headers }, function(res) {
159+
const sc = res.statusCode;
160+
if (sc === 301 || sc === 302 || sc === 307 || sc === 308) {
161+
res.resume();
162+
request(res.headers.location, redirectCount + 1);
163+
return;
164+
}
165+
if (sc !== 200) {
166+
res.resume();
167+
const hint = sc === 403 ? ' (rate limited - set GITHUB_TOKEN env var)' : '';
168+
reject(new Error('HTTP ' + sc + hint + ' fetching ' + reqUrl));
169+
return;
170+
}
171+
const chunks = [];
172+
res.on('data', function(chunk) { chunks.push(chunk); });
173+
res.on('end', function() { resolve(Buffer.concat(chunks)); });
174+
res.on('error', reject);
175+
}).on('error', reject);
176+
}
177+
178+
request(url, 0);
179+
});
180+
}
181+
182+
/**
183+
* Extract a tar.gz buffer into a directory using the system tar command.
184+
* @param {Buffer} buf
185+
* @param {string} destDir
186+
* @returns {Promise<void>}
187+
*/
188+
function extractTarGz(buf, destDir) {
189+
return new Promise(function(resolve, reject) {
190+
const tarDest = process.platform === 'win32' ? destDir.replace(/\\/g, '/') : destDir;
191+
const tar = cp.spawn('tar', ['xz', '-C', tarDest], {
192+
stdio: ['pipe', 'pipe', 'pipe']
193+
});
194+
let stderr = '';
195+
tar.stderr.on('data', function(d) { stderr += d; });
196+
tar.stdin.write(buf);
197+
tar.stdin.end();
198+
tar.on('close', function(code) {
199+
if (code !== 0) {
200+
reject(new Error('tar extraction failed (code ' + code + '): ' + stderr));
201+
} else {
202+
resolve();
203+
}
204+
});
205+
tar.on('error', reject);
206+
});
207+
}
208+
209+
/**
210+
* Extract a zip buffer into a directory using PowerShell Expand-Archive (Windows).
211+
* @param {Buffer} buf
212+
* @param {string} destDir
213+
* @param {string} binaryName
214+
* @returns {Promise<void>}
215+
*/
216+
function extractZip(buf, destDir, binaryName) {
217+
return new Promise(function(resolve, reject) {
218+
const tmpZip = path.join(os.tmpdir(), binaryName + '-' + Date.now() + '.zip');
219+
fs.writeFileSync(tmpZip, buf);
220+
const cmd = 'Expand-Archive -Path \'' + tmpZip + '\' -DestinationPath \'' + destDir + '\' -Force';
221+
const ps = cp.spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', cmd], {
222+
stdio: ['ignore', 'pipe', 'pipe']
223+
});
224+
let stderr = '';
225+
ps.stderr.on('data', function(d) { stderr += d; });
226+
ps.on('close', function(code) {
227+
try { fs.unlinkSync(tmpZip); } catch (e) { /* ignore */ }
228+
if (code !== 0) {
229+
reject(new Error('zip extraction failed (code ' + code + '): ' + stderr));
230+
} else {
231+
resolve();
232+
}
233+
});
234+
ps.on('error', reject);
235+
});
236+
}
237+
238+
/**
239+
* Download and install the binary for the current platform into ~/.agent-sh/bin/.
240+
* @param {string} ver
241+
* @returns {Promise<string>}
242+
*/
243+
async function downloadBinary(ver) {
244+
const platformKey = getPlatformKey();
245+
if (!platformKey) {
246+
throw new Error(
247+
'Unsupported platform: ' + process.platform + '-' + process.arch + '. ' +
248+
'Supported platforms: ' + Object.keys(PLATFORM_MAP).join(', ')
249+
);
250+
}
251+
252+
const url = buildDownloadUrl(ver, platformKey);
253+
process.stderr.write('Downloading ' + BINARY_NAME + ' v' + ver + ' for ' + platformKey + '...' + '\n');
254+
255+
const binPath = getBinaryPath();
256+
const binDir = path.dirname(binPath);
257+
fs.mkdirSync(binDir, { recursive: true });
258+
259+
let buf;
260+
try {
261+
buf = await downloadToBuffer(url);
262+
} catch (err) {
263+
throw new Error(
264+
'Failed to download ' + BINARY_NAME + ':\n' +
265+
' URL: ' + url + '\n' +
266+
' Error: ' + err.message + '\n\n' +
267+
'To install manually:\n' +
268+
' 1. Download: ' + url + '\n' +
269+
' 2. Extract the binary to: ' + binDir + '\n' +
270+
' 3. Ensure it is named: ' + path.basename(binPath)
271+
);
272+
}
273+
274+
if (process.platform === 'win32') {
275+
await extractZip(buf, binDir, path.basename(binPath));
276+
} else {
277+
await extractTarGz(buf, binDir);
278+
}
279+
280+
if (process.platform !== 'win32') {
281+
fs.chmodSync(binPath, 0o755);
282+
}
283+
284+
const installedVer = getVersion();
285+
if (!installedVer) {
286+
throw new Error(
287+
BINARY_NAME + ' was downloaded to ' + binPath + ' but could not be executed. ' +
288+
'Check the file is a valid binary for this platform.'
289+
);
290+
}
291+
292+
return binPath;
293+
}
294+
295+
// ---------------------------------------------------------------------------
296+
// Public API
297+
// ---------------------------------------------------------------------------
298+
299+
/**
300+
* Ensure the binary exists and meets the minimum version. Downloads if needed.
301+
* @param {Object} [options]
302+
* @param {string} [options.version]
303+
* @returns {Promise<string>}
304+
*/
305+
async function ensureBinary(options) {
306+
const opts = options || {};
307+
const targetVer = opts.version || ANALYZER_MIN_VERSION;
308+
const binPath = getBinaryPath();
309+
310+
if (fs.existsSync(binPath)) {
311+
const ver = getVersion();
312+
if (meetsMinimumVersion(ver, ANALYZER_MIN_VERSION)) {
313+
return binPath;
314+
}
315+
}
316+
317+
return downloadBinary(targetVer);
318+
}
319+
320+
/**
321+
* Sync version of ensureBinary. Downloads if needed via a child node process.
322+
* Prefer ensureBinary() unless a sync API is strictly required.
323+
* @param {Object} [options]
324+
* @param {string} [options.version]
325+
* @returns {string}
326+
*/
327+
function ensureBinarySync(options) {
328+
const binPath = getBinaryPath();
329+
330+
if (fs.existsSync(binPath)) {
331+
const ver = getVersion();
332+
if (meetsMinimumVersion(ver, ANALYZER_MIN_VERSION)) {
333+
return binPath;
334+
}
335+
}
336+
337+
const targetVer = (options && options.version) || ANALYZER_MIN_VERSION;
338+
const selfPath = __filename;
339+
const helperLines = [
340+
'var b = require(' + JSON.stringify(selfPath) + ');',
341+
'b.ensureBinary({ version: ' + JSON.stringify(targetVer) + ' })',
342+
' .then(function(p) { process.stdout.write(p); })',
343+
' .catch(function(e) { process.stderr.write(e.message); process.exit(1); });'
344+
];
345+
346+
try {
347+
const result = cp.execFileSync(process.execPath, ['-e', helperLines.join('\n')], {
348+
encoding: 'utf8',
349+
stdio: ['pipe', 'pipe', 'inherit'],
350+
timeout: 120000
351+
});
352+
return result.trim() || binPath;
353+
} catch (err) {
354+
throw new Error('Failed to ensure binary (sync): ' + err.message);
355+
}
356+
}
357+
358+
/**
359+
* Run agent-analyzer with the given arguments (sync). Downloads binary if needed.
360+
* @param {string[]} args
361+
* @param {Object} [options]
362+
* @returns {string}
363+
*/
364+
function runAnalyzer(args, options) {
365+
const binPath = ensureBinarySync();
366+
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
367+
if (!opts.stdio) opts.stdio = ['pipe', 'pipe', 'pipe'];
368+
const result = cp.execFileSync(binPath, args, opts);
369+
return typeof result === 'string' ? result : result.toString('utf8');
370+
}
371+
372+
/**
373+
* Run agent-analyzer with the given arguments asynchronously. Downloads binary if needed.
374+
* @param {string[]} args
375+
* @param {Object} [options]
376+
* @returns {Promise<string>}
377+
*/
378+
async function runAnalyzerAsync(args, options) {
379+
const binPath = await ensureBinary();
380+
const opts = Object.assign({ encoding: 'utf8', windowsHide: true }, options);
381+
const result = await execFileAsync(binPath, args, opts);
382+
return result.stdout;
383+
}
384+
385+
module.exports = {
386+
ensureBinary,
387+
ensureBinarySync,
388+
runAnalyzer,
389+
runAnalyzerAsync,
390+
getBinaryPath,
391+
getVersion,
392+
getPlatformKey,
393+
isAvailable,
394+
isAvailableAsync,
395+
meetsMinimumVersion,
396+
buildDownloadUrl,
397+
PLATFORM_MAP
398+
};

0 commit comments

Comments
 (0)