@vera-ai/cli 1.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/bin/vera.js +280 -166
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Code search for AI agents. Vera indexes your codebase using tree-sitter parsing and hybrid search (BM25 + vector similarity + optional cross-encoder reranking), then returns ranked code snippets as Markdown codeblocks by default, or JSON with `--json`.
4
4
 
5
- This package downloads and wraps the native Vera binary for your platform. On musl-based Linux (Alpine, NixOS), the correct static binary is selected automatically. Set `VERA_TARGET` to override target detection (e.g., `VERA_TARGET=x86_64-unknown-linux-musl npm install -g @vera-ai/cli`).
5
+ This package downloads and wraps the native Vera binary for your platform. It downloads the exact package version, checks the release archive size and SHA-256, and stores a completed binary cache. Later runs use that cache without network access. Older caches are downloaded and verified once before they can be reused offline. On Linux without glibc (such as Alpine or NixOS), the wrapper selects the musl binary. Other native targets use their platform system libraries; optional ONNX backends have additional runtime requirements. Set `VERA_TARGET` to override target detection (e.g., `VERA_TARGET=x86_64-unknown-linux-musl npm install -g @vera-ai/cli`).
6
6
 
7
- The default local embedding model is `minishlab/potion-code-16M-v2`; it runs locally on CPU on any supported machine, no GPU or ONNX Runtime needed. In the current Semble comparison, Vera v1.4.0 scored `0.8437` nDCG@10 versus Semble 0.5.5 at `0.8514` on Semble's own tuning corpus, and leads on the independent contamination set (`0.7674` vs `0.7655`) and on recall@5; Vera's index is 6.8x smaller (4.7 GB vs 32 GB). For the highest measured search quality, use the Qwen preset through OpenRouter. Full details live in the main repo docs.
7
+ The default local embedding model, `minishlab/potion-code-16M-v2`, runs on CPU and works offline after its first download. API mode is also available; Qwen/OpenRouter is recommended among API presets (paid usage). The [benchmark report](https://github.com/VeraTools/Vera/blob/master/docs/benchmarks.md) separates the full 1,251-task suite from smaller screening subsets, and the [agent benchmark](https://github.com/VeraTools/Vera/blob/master/docs/benchmarks-history.md#agent-level-benchmark) records context measurements and their sample limits.
8
8
 
9
9
  ## Install
10
10
 
@@ -19,9 +19,9 @@ vera setup --potion-code --index .
19
19
  vera search "authentication logic"
20
20
  ```
21
21
 
22
- `vera setup` with no flags runs an interactive wizard and offers to index the current project, defaulting to yes. An interactive search also offers to create a missing index. `vera setup --api` prompts for an OpenAI-compatible endpoint and key; the wizard offers presets for OpenAI, Jina, Voyage, and Qwen via OpenRouter, with the Qwen preset needing only one shared key (`qwen/qwen3-embedding-8b` + `qwen/qwen3-reranker-8b` via `https://openrouter.ai/api/v1`). Use `--yes` with `EMBEDDING_MODEL_*` variables for non-interactive setup.
22
+ `vera setup` runs the full wizard: configure a backend, optionally install agent skills, and optionally index. Local CPU is first and selected by default; API mode is second. Indexing defaults to Yes for local backends and No for API mode. `vera setup --api` uses the shorter API configuration flow, with Qwen/OpenRouter first and recommended (one shared key). See the [models guide](https://github.com/VeraTools/Vera/blob/master/docs/models.md#api-mode) for other providers and non-interactive setup.
23
23
 
24
- The preferred agent integration is the CLI plus the Vera skill: `vera agent install` installs it for supported coding agents and can add a short usage snippet to your project's `AGENTS.md`, `CLAUDE.md`, `COPILOT.md`, or editor rules file. Vera also ships an optional MCP server (`vera mcp`); see the [MCP guide](https://github.com/VeraTools/Vera/blob/master/docs/mcp.md) if your client is MCP-first.
24
+ The preferred agent integration is the CLI plus the Vera skill: `vera agent install` installs it for supported coding agents and can add a short usage snippet to your project's `AGENTS.md`, `CLAUDE.md`, `COPILOT.md`, or editor rules file. The client selector uses Space to toggle and Enter to continue. Empty selection changes nothing; unselected installations remain intact. Vera also ships an optional MCP server (`vera mcp`); see the [MCP guide](https://github.com/VeraTools/Vera/blob/master/docs/mcp.md) if your client is MCP-first.
25
25
 
26
26
  ## Common Tasks
27
27
 
package/bin/vera.js CHANGED
@@ -9,12 +9,21 @@ const http = require("node:http");
9
9
  const https = require("node:https");
10
10
  const os = require("node:os");
11
11
  const path = require("node:path");
12
- const { spawnSync } = require("node:child_process");
12
+ const { spawn, spawnSync } = require("node:child_process");
13
+ const { pipeline } = require("node:stream/promises");
14
+ const { Transform } = require("node:stream");
15
+ const zlib = require("node:zlib");
13
16
 
14
17
  const { version: packageVersion } = require("../package.json");
15
18
 
16
19
  const DEFAULT_REPO = "VeraTools/Vera";
17
20
  const MAX_REDIRECTS = 5;
21
+ const REQUEST_TIMEOUT = 30_000;
22
+ const DOWNLOAD_TIMEOUT = 180_000;
23
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
24
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
25
+ const MAX_BINARY_BYTES = 512 * 1024 * 1024;
26
+ const VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/;
18
27
 
19
28
  function parseArgs(argv) {
20
29
  if (argv.length === 0) {
@@ -26,20 +35,16 @@ function parseArgs(argv) {
26
35
 
27
36
  function detectMusl() {
28
37
  if (process.platform !== "linux") return false;
38
+ const result = spawnSync("ldd", ["--version"], {
39
+ stdio: ["pipe", "pipe", "pipe"], timeout: 5000,
40
+ });
41
+ const output = (result.stdout || "").toString() + (result.stderr || "").toString();
42
+ if (/musl/i.test(output)) return true;
43
+ if (!result.error && /glibc|GNU libc/i.test(output)) return false;
29
44
  try {
30
- const result = spawnSync("ldd", ["--version"], {
31
- stdio: ["pipe", "pipe", "pipe"],
32
- });
33
- const output = (result.stdout || "").toString() + (result.stderr || "").toString();
34
- return /musl/i.test(output);
45
+ return fs.readdirSync("/lib").some((entry) => entry.startsWith("ld-musl-"));
35
46
  } catch {
36
- // Fallback: check for musl dynamic linker.
37
- try {
38
- const entries = fs.readdirSync("/lib");
39
- return entries.some((e) => e.startsWith("ld-musl-"));
40
- } catch {
41
- return false;
42
- }
47
+ return false;
43
48
  }
44
49
  }
45
50
 
@@ -78,10 +83,6 @@ function manifestUrl(version) {
78
83
  return `${defaultReleaseBaseUrl()}/releases/download/v${version}/release-manifest.json`;
79
84
  }
80
85
 
81
- function latestManifestUrl() {
82
- return `${defaultReleaseBaseUrl()}/releases/latest/download/release-manifest.json`;
83
- }
84
-
85
86
  function defaultVeraHome() {
86
87
  return process.env.VERA_HOME || path.join(os.homedir(), ".vera");
87
88
  }
@@ -102,7 +103,8 @@ function currentInstallMethod() {
102
103
  async function readInstallMetadata() {
103
104
  try {
104
105
  const raw = await fsp.readFile(installMetadataPath(), "utf8");
105
- return JSON.parse(raw);
106
+ const value = JSON.parse(raw);
107
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
106
108
  } catch {
107
109
  return {};
108
110
  }
@@ -117,6 +119,8 @@ async function writeInstallMetadata({ installMethod, version, binaryPath, target
117
119
  version: version ?? current.version ?? null,
118
120
  binary_path: binaryPath ?? current.binary_path ?? null,
119
121
  target: target ?? current.target ?? null,
122
+ manifest_url: process.env.VERA_MANIFEST_URL || null,
123
+ requested_version: packageVersion,
120
124
  };
121
125
  const tmpPath = `${metadataPath}.tmp.${process.pid}`;
122
126
  await fsp.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
@@ -164,71 +168,70 @@ function shimName() {
164
168
  return process.platform === "win32" ? "vera.cmd" : "vera";
165
169
  }
166
170
 
167
- async function fetchText(url, redirects = 0) {
168
- const client = url.startsWith("https://") ? https : http;
171
+ function openResponse(url, timeout = REQUEST_TIMEOUT, redirects = 0, deadline = Date.now() + timeout) {
172
+ const parsed = new URL(url);
173
+ if (!["http:", "https:"].includes(parsed.protocol)) {
174
+ throw new Error("release downloads require an HTTP or HTTPS URL");
175
+ }
169
176
  return new Promise((resolve, reject) => {
170
- const request = client.get(url, (response) => {
177
+ const remaining = deadline - Date.now();
178
+ if (remaining <= 0) return reject(new Error("release download timed out"));
179
+ const client = parsed.protocol === "https:" ? https : http;
180
+ const request = client.get(parsed, (response) => {
171
181
  const status = response.statusCode || 0;
172
182
  if ([301, 302, 303, 307, 308].includes(status) && response.headers.location) {
173
183
  if (redirects >= MAX_REDIRECTS) {
174
- reject(new Error(`too many redirects fetching ${url}`));
175
- return;
184
+ reject(new Error("too many release redirects"));
185
+ } else {
186
+ resolve(Promise.resolve().then(() => openResponse(new URL(response.headers.location, parsed).toString(), timeout, redirects + 1, deadline)));
176
187
  }
177
-
178
- const nextUrl = new URL(response.headers.location, url).toString();
179
- resolve(fetchText(nextUrl, redirects + 1));
188
+ response.destroy();
180
189
  return;
181
190
  }
182
-
183
191
  if (status < 200 || status >= 300) {
192
+ response.destroy();
184
193
  reject(new Error(`request failed for ${url}: ${status}`));
185
194
  return;
186
195
  }
187
-
188
- let body = "";
189
- response.setEncoding("utf8");
190
- response.on("data", (chunk) => {
191
- body += chunk;
192
- });
193
- response.on("end", () => resolve(body));
196
+ resolve(response);
194
197
  });
195
-
198
+ const timer = setTimeout(() => request.destroy(new Error("release download timed out")), remaining);
199
+ request.on("close", () => clearTimeout(timer));
196
200
  request.on("error", reject);
197
201
  });
198
202
  }
199
203
 
200
- async function downloadFile(url, destination, redirects = 0) {
201
- const client = url.startsWith("https://") ? https : http;
202
- await fsp.mkdir(path.dirname(destination), { recursive: true });
203
-
204
- return new Promise((resolve, reject) => {
205
- const request = client.get(url, (response) => {
206
- const status = response.statusCode || 0;
207
- if ([301, 302, 303, 307, 308].includes(status) && response.headers.location) {
208
- if (redirects >= MAX_REDIRECTS) {
209
- reject(new Error(`too many redirects downloading ${url}`));
210
- return;
211
- }
212
-
213
- const nextUrl = new URL(response.headers.location, url).toString();
214
- response.resume();
215
- resolve(downloadFile(nextUrl, destination, redirects + 1));
216
- return;
217
- }
218
-
219
- if (status < 200 || status >= 300) {
220
- reject(new Error(`download failed for ${url}: ${status}`));
221
- return;
222
- }
204
+ async function fetchText(url, timeout = REQUEST_TIMEOUT) {
205
+ const response = await openResponse(url, timeout);
206
+ const chunks = [];
207
+ let size = 0;
208
+ for await (const chunk of response) {
209
+ size += chunk.length;
210
+ if (size > MAX_MANIFEST_BYTES) {
211
+ response.destroy();
212
+ throw new Error("release manifest exceeds its size limit");
213
+ }
214
+ chunks.push(chunk);
215
+ }
216
+ return Buffer.concat(chunks).toString("utf8");
217
+ }
223
218
 
224
- const file = fs.createWriteStream(destination);
225
- response.pipe(file);
226
- file.on("finish", () => file.close(resolve));
227
- file.on("error", reject);
219
+ async function downloadFile(url, destination, expectedSize, timeout = DOWNLOAD_TIMEOUT) {
220
+ let size = 0;
221
+ try {
222
+ const response = await openResponse(url, timeout);
223
+ const bound = new Transform({
224
+ transform(chunk, encoding, callback) {
225
+ size += chunk.length;
226
+ callback(size > expectedSize ? new Error("release archive exceeds its size limit") : null, chunk);
227
+ },
228
228
  });
229
-
230
- request.on("error", reject);
231
- });
229
+ await pipeline(response, bound, fs.createWriteStream(destination, { flags: "wx" }));
230
+ if (size !== expectedSize) throw new Error("release archive size mismatch");
231
+ } catch (error) {
232
+ await fsp.rm(destination, { force: true });
233
+ throw error;
234
+ }
232
235
  }
233
236
 
234
237
  async function sha256(filePath) {
@@ -242,50 +245,147 @@ async function sha256(filePath) {
242
245
  });
243
246
  }
244
247
 
245
- function runChecked(command, args) {
246
- const result = spawnSync(command, args, { stdio: "inherit" });
247
- if (result.error) {
248
- throw result.error;
249
- }
250
-
251
- if (result.status !== 0) {
252
- throw new Error(`${command} exited with status ${result.status}`);
253
- }
248
+ function safeMember(name) {
249
+ return name && !name.startsWith("/") && !/[\\:\x00]/.test(name) &&
250
+ name.replace(/\/$/, "").split("/").every((part) => part && part !== "." && part !== "..");
254
251
  }
255
252
 
256
- async function extractArchive(archivePath, destination) {
257
- await fsp.mkdir(destination, { recursive: true });
253
+ async function extractArchive(archivePath, destination, target) {
254
+ const expected = `vera-${target}/${binaryName()}`;
258
255
  if (archivePath.endsWith(".zip")) {
259
- runChecked("powershell", [
260
- "-NoProfile",
261
- "-Command",
262
- "Import-Module Microsoft.PowerShell.Archive; Expand-Archive",
263
- "-LiteralPath",
264
- archivePath,
265
- "-DestinationPath",
266
- destination,
267
- "-Force",
268
- ]);
256
+ // .NET reads only the selected entry; archive paths never become filesystem paths.
257
+ const script = `
258
+ $ErrorActionPreference = 'Stop'
259
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
260
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($env:VERA_INSTALL_ARCHIVE)
261
+ try {
262
+ $matches = @()
263
+ $total = 0
264
+ $names = @{}
265
+ if ($archive.Entries.Count -gt 1024) { throw 'Too many release archive members' }
266
+ foreach ($entry in $archive.Entries) {
267
+ $name = $entry.FullName
268
+ $type = ($entry.ExternalAttributes -shr 16) -band 61440
269
+ $parts = $name.TrimEnd('/').Split('/')
270
+ if ($names.ContainsKey($name) -or !$name -or $name.StartsWith('/') -or $name.Contains('\\') -or $name.Contains(':') -or
271
+ ($parts | Where-Object { $_ -eq '..' -or $_ -eq '.' -or $_ -eq '' }) -or
272
+ ($type -ne 0 -and $type -ne 32768 -and $type -ne 16384)) { throw 'Unsafe release archive member' }
273
+ $names[$name] = $true
274
+ $total += $entry.Length
275
+ if ($total -gt ${MAX_BINARY_BYTES}) { throw 'Release archive exceeds its size limit' }
276
+ if ($name -ceq $env:VERA_INSTALL_MEMBER) {
277
+ if ($type -eq 16384) { throw 'Release binary must be a regular file' }
278
+ $matches += $entry
279
+ }
280
+ }
281
+ if ($matches.Count -ne 1 -or $matches[0].Length -le 0) { throw 'Release archive must contain exactly one Vera binary' }
282
+ $source = $matches[0].Open()
283
+ $output = [System.IO.File]::Open($env:VERA_INSTALL_BINARY, [System.IO.FileMode]::CreateNew)
284
+ try {
285
+ $buffer = New-Object byte[] 65536
286
+ $copied = 0
287
+ while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) {
288
+ $copied += $count
289
+ if ($copied -gt $matches[0].Length -or $copied -gt ${MAX_BINARY_BYTES}) { throw 'Release binary exceeds its size limit' }
290
+ $output.Write($buffer, 0, $count)
291
+ }
292
+ } finally { $source.Dispose(); $output.Dispose() }
293
+ if ((Get-Item -LiteralPath $env:VERA_INSTALL_BINARY).Length -ne $matches[0].Length) { throw 'Incomplete release binary' }
294
+ } finally { $archive.Dispose() }
295
+ `;
296
+ const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
297
+ stdio: "inherit", timeout: 30_000,
298
+ env: { ...process.env, VERA_INSTALL_ARCHIVE: archivePath, VERA_INSTALL_BINARY: destination, VERA_INSTALL_MEMBER: expected },
299
+ });
300
+ if (result.error) throw result.error;
301
+ if (result.status !== 0) throw new Error("release ZIP extraction failed");
269
302
  return;
270
303
  }
271
304
 
272
- runChecked("tar", ["-xzf", archivePath, "-C", destination]);
305
+ const output = await fsp.open(destination, "wx");
306
+ const input = fs.createReadStream(archivePath);
307
+ const gunzip = zlib.createGunzip();
308
+ input.on("error", (error) => gunzip.destroy(error));
309
+ input.pipe(gunzip);
310
+ let buffer = Buffer.alloc(0);
311
+ let remaining = 0;
312
+ let padding = 0;
313
+ let selected = false;
314
+ let matches = 0;
315
+ let expanded = 0;
316
+ let members = 0;
317
+ let ended = false;
318
+ const names = new Set();
319
+ try {
320
+ for await (const chunk of gunzip) {
321
+ expanded += chunk.length;
322
+ if (expanded > MAX_BINARY_BYTES + 1024 * 1024) throw new Error("release archive exceeds its size limit");
323
+ buffer = Buffer.concat([buffer, chunk]);
324
+ while (buffer.length) {
325
+ if (remaining || padding) {
326
+ const count = Math.min(buffer.length, remaining || padding);
327
+ if (remaining) {
328
+ if (selected) await output.writeFile(buffer.subarray(0, count));
329
+ remaining -= count;
330
+ } else padding -= count;
331
+ buffer = buffer.subarray(count);
332
+ continue;
333
+ }
334
+ if (buffer.length < 512) break;
335
+ const header = buffer.subarray(0, 512);
336
+ buffer = buffer.subarray(512);
337
+ if (header.every((byte) => byte === 0)) { ended = true; continue; }
338
+ if (ended || ++members > 1024) throw new Error("invalid release tar archive");
339
+ const field = (start, length) => header.subarray(start, start + length).toString("utf8").split("\0")[0];
340
+ const octal = (start, length) => {
341
+ const value = field(start, length).trim();
342
+ if (!/^[0-7]+$/.test(value)) throw new Error("invalid release tar header");
343
+ return parseInt(value, 8);
344
+ };
345
+ const checksum = octal(148, 8);
346
+ const actual = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
347
+ if (actual !== checksum) throw new Error("invalid release tar checksum");
348
+ const prefix = field(345, 155);
349
+ const name = (prefix ? `${prefix}/` : "") + field(0, 100);
350
+ const type = String.fromCharCode(header[156]);
351
+ if (names.has(name) || !safeMember(name) || !["0", "\0", "5"].includes(type)) throw new Error("unsafe release archive member");
352
+ names.add(name);
353
+ remaining = octal(124, 12);
354
+ if (remaining > MAX_BINARY_BYTES || (type === "5" && remaining)) throw new Error("invalid release tar size");
355
+ padding = (512 - remaining % 512) % 512;
356
+ selected = name === expected && type !== "5";
357
+ if (selected && (++matches !== 1 || remaining === 0)) throw new Error("release archive must contain exactly one Vera binary");
358
+ }
359
+ }
360
+ if (remaining || padding || buffer.some((byte) => byte !== 0) || !ended || matches !== 1) {
361
+ throw new Error("incomplete release tar archive or missing Vera binary");
362
+ }
363
+ } finally {
364
+ input.destroy();
365
+ gunzip.destroy();
366
+ await output.close();
367
+ }
273
368
  }
274
369
 
275
370
  async function createShim(binaryPath) {
276
371
  const binDir = pickUserBinDir();
277
372
  await fsp.mkdir(binDir, { recursive: true });
278
373
  const shimPath = path.join(binDir, shimName());
279
-
280
- if (process.platform === "win32") {
281
- const contents = `@echo off\r\n"${binaryPath}" %*\r\n`;
282
- await fsp.writeFile(shimPath, contents, "utf8");
283
- } else {
284
- const contents = `#!/bin/sh\nexec "${binaryPath}" "$@"\n`;
285
- await fsp.writeFile(shimPath, contents, "utf8");
286
- await fsp.chmod(shimPath, 0o755);
374
+ if (path.join(await fsp.realpath(binDir), shimName()) === await fsp.realpath(binaryPath)) return shimPath;
375
+ const staging = await fsp.mkdtemp(path.join(binDir, ".vera-shim-"));
376
+ const stagedShim = path.join(staging, shimName());
377
+ try {
378
+ if (process.platform === "win32") {
379
+ const contents = `@echo off\r\nsetlocal DisableDelayedExpansion\r\n"${binaryPath.replace(/%/g, "%%")}" %*\r\n`;
380
+ await fsp.writeFile(stagedShim, contents, "utf8");
381
+ } else {
382
+ const quoted = "'" + binaryPath.replace(/'/g, "'\"'\"'") + "'";
383
+ await fsp.writeFile(stagedShim, `#!/bin/sh\nexec ${quoted} "$@"\n`, { mode: 0o755 });
384
+ }
385
+ await fsp.rename(stagedShim, shimPath);
386
+ } finally {
387
+ await fsp.rm(staging, { recursive: true, force: true });
287
388
  }
288
-
289
389
  return shimPath;
290
390
  }
291
391
 
@@ -294,91 +394,101 @@ function isOnPath(dirPath) {
294
394
  }
295
395
 
296
396
  async function loadManifest() {
297
- const primaryUrl = manifestUrl(packageVersion);
397
+ const manifest = JSON.parse(await fetchText(manifestUrl(packageVersion)));
398
+ if (!manifest || typeof manifest.version !== "string" || !VERSION_PATTERN.test(manifest.version)) {
399
+ throw new Error("invalid release version");
400
+ }
401
+ if (!process.env.VERA_MANIFEST_URL && manifest.version !== packageVersion) {
402
+ throw new Error(`requested Vera ${packageVersion}, manifest contains ${manifest.version}`);
403
+ }
404
+ return manifest;
405
+ }
406
+
407
+ async function cachedBinary(target) {
408
+ let version = packageVersion;
409
+ if (process.env.VERA_MANIFEST_URL) {
410
+ const metadata = await readInstallMetadata();
411
+ if (metadata.manifest_url !== process.env.VERA_MANIFEST_URL || metadata.requested_version !== packageVersion) return null;
412
+ version = metadata.version;
413
+ if (typeof version !== "string" || !VERSION_PATTERN.test(version)) return null;
414
+ }
415
+ const binaryPath = path.join(defaultVeraHome(), "bin", version, target, binaryName());
298
416
  try {
299
- const manifestText = await fetchText(primaryUrl);
300
- return JSON.parse(manifestText);
301
- } catch (error) {
302
- if (process.env.VERA_MANIFEST_URL) {
303
- throw error;
417
+ const receipt = JSON.parse(await fsp.readFile(`${binaryPath}.receipt.json`, "utf8"));
418
+ const stat = await fsp.lstat(binaryPath);
419
+ if (receipt && stat.isFile() && stat.size > 0 && receipt.version === version && receipt.target === target &&
420
+ receipt.size === stat.size && receipt.sha256 === await sha256(binaryPath)) {
421
+ return { binaryPath, version };
304
422
  }
423
+ } catch (error) {
424
+ if (!["ENOENT", "ENOTDIR"].includes(error.code) && !(error instanceof SyntaxError)) throw error;
425
+ }
426
+ return null;
427
+ }
305
428
 
306
- const fallbackText = await fetchText(latestManifestUrl());
307
- return JSON.parse(fallbackText);
429
+ async function finishInstall(binaryPath, version, target, announce = false) {
430
+ const shimPath = await createShim(binaryPath);
431
+ if (announce && !isOnPath(path.dirname(shimPath))) {
432
+ console.error(`Added Vera to ${path.dirname(shimPath)}. Add that directory to PATH to run \`vera\` directly.`);
308
433
  }
434
+ await writeInstallMetadata({ installMethod: currentInstallMethod(), version, binaryPath, target });
435
+ return { binaryPath, version };
309
436
  }
310
437
 
311
438
  async function ensureBinaryInstalled() {
312
- const manifest = await loadManifest();
313
439
  const target = resolveTarget(process.platform, process.arch);
440
+ if (!/^[A-Za-z0-9_-]+$/.test(target)) throw new Error("invalid release target");
441
+ const cached = await cachedBinary(target);
442
+ if (cached) return finishInstall(cached.binaryPath, cached.version, target);
443
+ const manifest = await loadManifest();
314
444
  const asset = manifest.assets && manifest.assets[target];
315
- if (!asset) {
316
- throw new Error(`no release asset for target ${target}`);
445
+ if (!asset) throw new Error(`no release asset for target ${target}`);
446
+ const extension = target.endsWith("windows-msvc") ? ".zip" : ".tar.gz";
447
+ if (asset.archive !== `vera-${target}${extension}` || !Number.isSafeInteger(asset.size) ||
448
+ asset.size <= 0 || asset.size > MAX_ARCHIVE_BYTES || !/^[0-9a-f]{64}$/.test(asset.sha256)) {
449
+ throw new Error("invalid release archive metadata");
317
450
  }
318
-
319
451
  const version = manifest.version;
320
452
  const installDir = path.join(defaultVeraHome(), "bin", version, target);
321
453
  const binaryPath = path.join(installDir, binaryName());
322
- if (fs.existsSync(binaryPath)) {
323
- const stat = await fsp.stat(binaryPath);
324
- if (stat.size > 1_000_000) {
325
- await createShim(binaryPath);
326
- await writeInstallMetadata({
327
- installMethod: currentInstallMethod(),
328
- version,
329
- binaryPath,
330
- target,
331
- });
332
- return { binaryPath, version };
333
- }
334
- // Stale or broken file (e.g. leftover shim from interrupted install).
335
- await fsp.rm(binaryPath);
336
- }
337
-
338
- const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vera-install-"));
454
+ await fsp.mkdir(installDir, { recursive: true });
455
+ const tempRoot = await fsp.mkdtemp(path.join(installDir, ".install-"));
339
456
  try {
340
457
  const archivePath = path.join(tempRoot, asset.archive);
341
- const extractDir = path.join(tempRoot, "extract");
342
-
458
+ const stagedBinary = path.join(tempRoot, binaryName());
343
459
  console.error(`Downloading Vera ${version} for ${target}...`);
344
- await downloadFile(asset.download_url, archivePath);
345
-
346
- const actualSha = await sha256(archivePath);
347
- if (actualSha !== asset.sha256) {
348
- throw new Error(`checksum mismatch for ${asset.archive}`);
349
- }
350
-
351
- await extractArchive(archivePath, extractDir);
352
- const extractedBinary = path.join(extractDir, `vera-${target}`, binaryName());
353
- await fsp.mkdir(installDir, { recursive: true });
354
- await fsp.copyFile(extractedBinary, binaryPath);
355
- if (process.platform !== "win32") {
356
- await fsp.chmod(binaryPath, 0o755);
357
- }
358
-
359
- const shimPath = await createShim(binaryPath);
360
- if (!isOnPath(path.dirname(shimPath))) {
361
- console.error(`Added Vera to ${path.dirname(shimPath)}. Add that directory to PATH to run \`vera\` directly.`);
362
- }
363
-
364
- await writeInstallMetadata({
365
- installMethod: currentInstallMethod(),
366
- version,
367
- binaryPath,
368
- target,
369
- });
370
- return { binaryPath, version };
460
+ await downloadFile(asset.download_url, archivePath, asset.size);
461
+ if (await sha256(archivePath) !== asset.sha256) throw new Error(`checksum mismatch for ${asset.archive}`);
462
+ await extractArchive(archivePath, stagedBinary, target);
463
+ if (process.platform !== "win32") await fsp.chmod(stagedBinary, 0o755);
464
+ const stat = await fsp.stat(stagedBinary);
465
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_BINARY_BYTES) throw new Error("invalid release binary");
466
+ const receiptPath = path.join(tempRoot, "receipt.json");
467
+ await fsp.writeFile(receiptPath, JSON.stringify({ version, target, size: stat.size, sha256: await sha256(stagedBinary) }));
468
+ await fsp.rename(stagedBinary, binaryPath);
469
+ await fsp.rename(receiptPath, `${binaryPath}.receipt.json`);
371
470
  } finally {
372
471
  await fsp.rm(tempRoot, { recursive: true, force: true });
373
472
  }
473
+ return finishInstall(binaryPath, version, target, true);
374
474
  }
375
475
 
376
- function runBinary(binaryPath, args) {
377
- const result = spawnSync(binaryPath, args, { stdio: "inherit" });
378
- if (result.error) {
379
- throw result.error;
476
+ async function runBinary(binaryPath, args) {
477
+ const child = spawn(binaryPath, args, { stdio: "inherit" });
478
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
479
+ const forward = new Map(signals.map((signal) => [signal, () => child.kill(signal)]));
480
+ for (const [signal, handler] of forward) process.on(signal, handler);
481
+ try {
482
+ const result = await new Promise((resolve, reject) => {
483
+ child.on("error", reject);
484
+ child.on("exit", (code, signal) => resolve({ code, signal }));
485
+ });
486
+ for (const [signal, handler] of forward) process.removeListener(signal, handler);
487
+ if (result.signal && process.platform !== "win32") process.kill(process.pid, result.signal);
488
+ else process.exitCode = result.code ?? 1;
489
+ } finally {
490
+ for (const [signal, handler] of forward) process.removeListener(signal, handler);
380
491
  }
381
- process.exit(result.status ?? 0);
382
492
  }
383
493
 
384
494
  async function main() {
@@ -397,19 +507,23 @@ async function main() {
397
507
 
398
508
  if (command === "install") {
399
509
  console.error(`Vera ${version} installed.`);
400
- runBinary(binaryPath, ["agent", "install", ...rest]);
510
+ await runBinary(binaryPath, ["agent", "install", ...rest]);
401
511
  return;
402
512
  }
403
513
 
404
514
  if (command === "help") {
405
- runBinary(binaryPath, ["--help"]);
515
+ await runBinary(binaryPath, ["--help"]);
406
516
  return;
407
517
  }
408
518
 
409
- runBinary(binaryPath, [command, ...rest]);
519
+ await runBinary(binaryPath, [command, ...rest]);
520
+ }
521
+
522
+ if (require.main === module) {
523
+ main().catch((error) => {
524
+ console.error(error.message);
525
+ process.exitCode = 1;
526
+ });
410
527
  }
411
528
 
412
- main().catch((error) => {
413
- console.error(error.message);
414
- process.exit(1);
415
- });
529
+ module.exports = { detectMusl, fetchText, downloadFile, extractArchive, ensureBinaryInstalled, createShim, runBinary };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vera-ai/cli",
3
- "version": "1.4.2",
3
+ "version": "2.0.0",
4
4
  "description": "Bootstrap installer and wrapper for the Vera CLI",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/VeraTools/Vera#readme",