fix: self-heal missing platform bundle from GitHub Releases (#303) (#335)

Installing from a registry mirror (npmmirror/cnpm) that hadn't mirrored the
per-platform optionalDependency left codegraph failing with "no prebuilt
bundle for <platform>" — npm treats an unfetchable optional dep as success and
silently skips it. The npm-shim now self-heals: when the bundle is missing it
downloads the matching archive from GitHub Releases (checksum-verified, with a
download timeout) and caches it, so a global install works on any registry.

release.yml now publishes SHA256SUMS and triggers an npmmirror sync after
publish. Adds hermetic tests for the shim (resolution, cache reuse, disable
knob, download + checksum match/mismatch/absent).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-22 11:38:28 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 5aae9c4bbf
commit 15072aa29f
5 changed files with 477 additions and 32 deletions
+25 -2
View File
@@ -36,6 +36,13 @@ jobs:
done done
ls -lh release ls -lh release
- name: Generate SHA256SUMS
# Published as a release asset; the npm launcher verifies downloaded
# bundles against it (basenames only, so its path.basename match works).
run: |
( cd release && sha256sum codegraph-* > SHA256SUMS )
cat release/SHA256SUMS
- name: Resolve version - name: Resolve version
id: ver id: ver
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
@@ -58,9 +65,9 @@ jobs:
TAG="v${{ steps.ver.outputs.version }}" TAG="v${{ steps.ver.outputs.version }}"
# Idempotent: create the release once, otherwise (re-run) refresh assets. # Idempotent: create the release once, otherwise (re-run) refresh assets.
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" release/codegraph-* --clobber gh release upload "$TAG" release/codegraph-* release/SHA256SUMS --clobber
else else
gh release create "$TAG" release/codegraph-* --title "$TAG" --notes-file notes.md gh release create "$TAG" release/codegraph-* release/SHA256SUMS --title "$TAG" --notes-file notes.md
fi fi
- name: Publish to npm - name: Publish to npm
@@ -96,3 +103,19 @@ jobs:
[ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; } [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; }
echo "verified $name@$V" echo "verified $name@$V"
done done
- name: Sync packages to npmmirror
# npmmirror/cnpm mirror lazily and frequently never pull the per-platform
# optionalDependencies on their own, so `npm i` there fails with
# "no prebuilt bundle" (issue #303). Nudge a sync now so mirror users get
# the bundle without waiting. Best-effort — the launcher also self-heals
# from GitHub Releases — so a mirror hiccup never fails the release.
continue-on-error: true
run: |
for dir in release/npm/codegraph-* release/npm/main; do
name=$(node -p "require('./$dir/package.json').name")
enc=$(node -p "encodeURIComponent(require('./$dir/package.json').name)")
echo "sync $name"
curl -s -X PUT "https://registry.npmmirror.com/-/package/$enc/syncs" || true
echo
done
+26
View File
@@ -7,6 +7,31 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.4] - 2026-05-22
### Added
- **Release archives now ship with a `SHA256SUMS` file**, and the npm launcher
verifies the bundle it downloads against it — a mismatch aborts before
anything runs. Releases published before this change have no checksum file, so
the verification is skipped (not failed) when none is available.
### Fixed
- **`codegraph: no prebuilt bundle for <platform>` after installing through a
registry mirror.** Installing `@colbymchenry/codegraph` from a registry that
hadn't mirrored the matching per-platform package — most often the
npmmirror/cnpm mirrors, but any lazily-syncing mirror or corporate proxy can
do it — left every command failing with `no prebuilt bundle for <platform>`.
The runtime ships as a per-platform `optionalDependency`, and npm treats an
optional package it can't fetch as a success and silently skips it, so the
bundle simply went missing. The launcher now self-heals: when the platform
bundle isn't installed, it downloads the same archive from GitHub Releases
(cached under `~/.codegraph/bundles/` for next time) and runs that — so a
global install works even on a mirror that never carried the platform package.
Set `CODEGRAPH_NO_DOWNLOAD=1` to disable the network fallback, or
`CODEGRAPH_DOWNLOAD_BASE=<url>` to point it at your own mirror of the release
archives; the standalone `install.sh` remains the no-Node alternative. Resolves
[#303](https://github.com/colbymchenry/codegraph/issues/303).
## [0.9.3] - 2026-05-22 ## [0.9.3] - 2026-05-22
### Added ### Added
@@ -132,6 +157,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
find its bundle. The release pipeline now verifies every package reached the find its bundle. The release pipeline now verifies every package reached the
registry (and is idempotent), so a release can't pass green-but-broken again. registry (and is idempotent), so a release can't pass green-but-broken again.
[0.9.4]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.4
[0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3 [0.9.3]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.3
[0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 [0.9.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2
[0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1
+208
View File
@@ -0,0 +1,208 @@
/**
* npm thin-installer launcher (`scripts/npm-shim.js`) tests.
*
* The shim runs on the user's own Node, locates the per-platform optionalDependency
* bundle, and — when a registry mirror failed to deliver it (issue #303) — falls
* back to downloading the bundle from GitHub Releases. These tests exercise that
* shim as a real subprocess from a temp "main package" dir (its own package.json
* + node_modules), so resolution and version lookup behave hermetically.
*
* The download/checksum paths run against a local self-signed HTTPS server via
* CODEGRAPH_DOWNLOAD_BASE — no real network, no published release needed. The
* shim is launched with async `spawn` (not spawnSync), so the test's event loop
* stays free to serve those requests.
*
* POSIX only: the fake bundle launcher is a shell script and extraction uses the
* system `tar`. Skipped on Windows (where the shim's exec path differs anyway).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, execSync } from 'child_process';
import * as https from 'https';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import type { AddressInfo } from 'net';
const SHIM_SRC = path.join(__dirname, '..', 'scripts', 'npm-shim.js');
const target = `${process.platform}-${process.arch}`;
const asset = `codegraph-${target}.tar.gz`;
const isWindows = process.platform === 'win32';
function hasOpenssl(): boolean {
try { execSync('openssl version', { stdio: 'ignore' }); return true; } catch { return false; }
}
const CAN_NET = !isWindows && hasOpenssl();
function mkTmp(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-shim-${label}-`));
}
// A temp dir standing in for the installed @colbymchenry/codegraph main package.
function makePkg(version = '9.9.9-test'): string {
const dir = mkTmp('pkg');
fs.copyFileSync(SHIM_SRC, path.join(dir, 'npm-shim.js'));
fs.writeFileSync(path.join(dir, 'package.json'),
JSON.stringify({ name: '@colbymchenry/codegraph', version }) + '\n');
return dir;
}
// A fake bundle launcher that prints a marker + its args, so we can prove the
// shim found and exec'd it (and passed args through).
function writeLauncher(binDir: string): void {
fs.mkdirSync(binDir, { recursive: true });
const p = path.join(binDir, 'codegraph');
fs.writeFileSync(p, '#!/bin/sh\necho "FAKE_BUNDLE_RAN args:$*"\n');
fs.chmodSync(p, 0o755);
}
// Launch the shim with async spawn so the in-process HTTPS server can respond
// while it runs (spawnSync would block this event loop and deadlock).
function runShim(pkgDir: string, args: string[], env: Record<string, string>) {
return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
const child = spawn(process.execPath, [path.join(pkgDir, 'npm-shim.js'), ...args], {
env: { ...process.env, ...env },
});
let stdout = '', stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
describe.skipIf(isWindows)('npm-shim launcher', () => {
it('runs the installed optional-dependency bundle without any download', async () => {
const pkg = makePkg();
const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`);
writeLauncher(path.join(platformPkg, 'bin'));
fs.writeFileSync(path.join(platformPkg, 'package.json'),
JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n');
const cache = mkTmp('cache');
const r = await runShim(pkg, ['--probe-abc'], { CODEGRAPH_INSTALL_DIR: cache });
expect(r.status).toBe(0);
expect(r.stdout).toContain('FAKE_BUNDLE_RAN');
expect(r.stdout).toContain('--probe-abc'); // args passed through
expect(r.stderr).not.toContain('downloading'); // never reached the fallback
expect(fs.existsSync(path.join(cache, 'bundles'))).toBe(false);
});
it('uses an already-cached bundle even when downloads are disabled', async () => {
const pkg = makePkg('1.2.3-cached');
const cache = mkTmp('cache');
writeLauncher(path.join(cache, 'bundles', `${target}-1.2.3-cached`, 'bin'));
const r = await runShim(pkg, ['--probe-xyz'], {
CODEGRAPH_INSTALL_DIR: cache,
CODEGRAPH_NO_DOWNLOAD: '1',
});
expect(r.status).toBe(0);
expect(r.stdout).toContain('FAKE_BUNDLE_RAN');
expect(r.stdout).toContain('--probe-xyz');
expect(r.stderr).toBe('');
});
it('prints actionable guidance and exits 1 when disabled with no bundle', async () => {
const pkg = makePkg();
const r = await runShim(pkg, ['--version'], {
CODEGRAPH_INSTALL_DIR: mkTmp('cache'),
CODEGRAPH_NO_DOWNLOAD: '1',
});
expect(r.status).toBe(1);
expect(r.stderr).toContain(`no prebuilt bundle for ${target}`);
expect(r.stderr).toContain(`@colbymchenry/codegraph-${target}`);
expect(r.stderr).toContain('--registry=https://registry.npmjs.org');
expect(r.stderr).toContain('install.sh');
});
});
describe.skipIf(!CAN_NET)('npm-shim download fallback (local HTTPS)', () => {
let server: https.Server;
let port = 0;
let fixtureBytes: Buffer;
let fixtureSha: string;
let sumsBody: string | null = null; // per-test: SHA256SUMS contents, or null for 404
beforeAll(async () => {
// Self-signed cert for the mock release host.
const cdir = mkTmp('tls');
const keyP = path.join(cdir, 'key.pem');
const certP = path.join(cdir, 'cert.pem');
execSync(
`openssl req -x509 -newkey rsa:2048 -nodes -keyout ${keyP} -out ${certP} -days 1 -subj "/CN=localhost"`,
{ stdio: 'ignore' },
);
// Build a fake bundle archive (codegraph-<target>/bin/codegraph), like a real release asset.
const work = mkTmp('fixture');
writeLauncher(path.join(work, `codegraph-${target}`, 'bin'));
const archive = path.join(work, asset);
execSync(`tar -czf ${JSON.stringify(archive)} -C ${JSON.stringify(work)} codegraph-${target}`);
fixtureBytes = fs.readFileSync(archive);
fixtureSha = crypto.createHash('sha256').update(fixtureBytes).digest('hex');
server = https.createServer({ key: fs.readFileSync(keyP), cert: fs.readFileSync(certP) }, (req, res) => {
const url = req.url || '';
if (url.endsWith(`/${asset}`)) {
res.writeHead(200); res.end(fixtureBytes);
} else if (url.endsWith('/SHA256SUMS')) {
if (sumsBody === null) { res.writeHead(404); res.end('not found'); }
else { res.writeHead(200); res.end(sumsBody); }
} else {
res.writeHead(404); res.end('not found');
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
}, 30000);
afterAll(() => { server?.close(); });
function netEnv(cache: string): Record<string, string> {
return {
CODEGRAPH_INSTALL_DIR: cache,
CODEGRAPH_DOWNLOAD_BASE: `https://127.0.0.1:${port}`,
NODE_TLS_REJECT_UNAUTHORIZED: '0',
};
}
it('downloads, verifies the checksum, extracts, and execs the bundle', async () => {
sumsBody = `${fixtureSha} ${asset}\n`;
const pkg = makePkg('5.0.0-net');
const cache = mkTmp('cache');
const r = await runShim(pkg, ['--probe-net'], netEnv(cache));
expect(r.stderr).toContain('downloading');
expect(r.stderr).toContain('checksum verified');
expect(r.status).toBe(0);
expect(r.stdout).toContain('FAKE_BUNDLE_RAN');
expect(r.stdout).toContain('--probe-net');
expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-net`, 'bin', 'codegraph'))).toBe(true);
}, 20000);
it('aborts (exit 1) on a checksum mismatch and caches nothing', async () => {
sumsBody = `${'0'.repeat(64)} ${asset}\n`;
const pkg = makePkg('5.0.0-bad');
const cache = mkTmp('cache');
const r = await runShim(pkg, ['--version'], netEnv(cache));
expect(r.status).toBe(1);
expect(r.stderr).toContain('checksum mismatch');
expect(r.stdout).not.toContain('FAKE_BUNDLE_RAN'); // never exec'd a tampered bundle
expect(fs.existsSync(path.join(cache, 'bundles', `${target}-5.0.0-bad`))).toBe(false);
}, 20000);
it('proceeds when no SHA256SUMS is published (older releases)', async () => {
sumsBody = null; // 404
const pkg = makePkg('5.0.0-nosums');
const cache = mkTmp('cache');
const r = await runShim(pkg, ['--version'], netEnv(cache));
expect(r.status).toBe(0);
expect(r.stderr).toContain('downloading');
expect(r.stderr).not.toContain('checksum verified'); // skipped, not failed
expect(r.stdout).toContain('FAKE_BUNDLE_RAN');
}, 20000);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.9.3", "version": "0.9.4",
"description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.", "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+217 -29
View File
@@ -11,48 +11,236 @@
// (with node:sqlite), regardless of the user's Node version. The user's Node is // (with node:sqlite), regardless of the user's Node version. The user's Node is
// only ever a launcher; even an ancient version can run this file. // only ever a launcher; even an ancient version can run this file.
// //
// Self-heal (issue #303): some registries — notably the npmmirror/cnpm mirrors,
// and some corporate proxies — don't reliably mirror the per-platform
// optionalDependencies. npm treats an unfetchable optional dep as success and
// silently skips it, so the bundle goes missing and every command fails. When
// the installed bundle can't be resolved, this shim falls back to downloading
// the matching bundle straight from GitHub Releases — the very archive
// install.sh uses — into a cache dir, then runs that. Knobs:
// CODEGRAPH_NO_DOWNLOAD=1 disable the network fallback (print guidance)
// CODEGRAPH_INSTALL_DIR=DIR cache location (default: ~/.codegraph)
// CODEGRAPH_DOWNLOAD_BASE=URL release-download base (for mirrors/air-gapped)
//
// Wired up at release time as the main package's `bin`: // Wired up at release time as the main package's `bin`:
// "bin": { "codegraph": "scripts/npm-shim.js" } // "bin": { "codegraph": "npm-shim.js" }
// with the platform packages listed in `optionalDependencies`. // with the platform packages listed in `optionalDependencies`.
var childProcess = require('child_process'); var childProcess = require('child_process');
var fs = require('fs');
var os = require('os');
var path = require('path');
var target = process.platform + '-' + process.arch; // e.g. darwin-arm64, linux-x64 var target = process.platform + '-' + process.arch; // e.g. darwin-arm64, linux-x64
var pkg = '@colbymchenry/codegraph-' + target; var pkg = '@colbymchenry/codegraph-' + target;
var isWindows = process.platform === 'win32'; var isWindows = process.platform === 'win32';
var REPO = 'colbymchenry/codegraph';
// On Windows the bundle's launcher is a .cmd batch file. Modern Node refuses to main().catch(function (e) {
// spawn .cmd/.bat directly — spawnSync throws EINVAL (the CVE-2024-27980 process.stderr.write('codegraph: ' + (e && e.message ? e.message : String(e)) + '\n');
// hardening, observed on Node 24). So on Windows we skip the .cmd and invoke the process.exit(1);
// bundled node.exe against the app entry point directly. On unix the bin launcher });
// is a shell script that spawns cleanly.
var command, args; async function main() {
try { // Happy path: the npm-installed optional dependency. Fall back to a download
if (isWindows) { // when the registry didn't deliver it.
command = require.resolve(pkg + '/node.exe'); var resolved = resolveInstalledBundle() || (await selfHealBundle());
var entry = require.resolve(pkg + '/lib/dist/bin/codegraph.js'); var res = childProcess.spawnSync(resolved.command, resolved.args, { stdio: 'inherit' });
// --liftoff-only: keep tree-sitter's WASM grammars off V8's turboshaft tier if (res.error) {
// to avoid the Zone OOM on Node >= 22 (issues #293/#298). The unix launcher process.stderr.write('codegraph: ' + res.error.message + '\n');
// passes this too; on Windows we invoke node.exe directly so add it here. process.exit(1);
args = ['--liftoff-only', entry].concat(process.argv.slice(2));
} else {
command = require.resolve(pkg + '/bin/codegraph');
args = process.argv.slice(2);
} }
} catch (e) { process.exit(res.status === null ? 1 : res.status);
}
// Resolve the launcher from the installed per-platform optionalDependency.
// Returns {command, args} or null if the package isn't installed.
function resolveInstalledBundle() {
try {
if (isWindows) {
// Modern Node refuses to spawn the bundle's .cmd directly (EINVAL, the
// CVE-2024-27980 hardening on Node 24), so invoke the bundled node.exe
// against the app entry point and pass --liftoff-only here.
var nodeExe = require.resolve(pkg + '/node.exe');
var entry = require.resolve(pkg + '/lib/dist/bin/codegraph.js');
return { command: nodeExe, args: liftoff(entry) };
}
return { command: require.resolve(pkg + '/bin/codegraph'), args: process.argv.slice(2) };
} catch (e) {
return null;
}
}
// Locate the launcher inside an extracted GitHub bundle directory (same
// node/lib/bin layout as the npm platform package). Returns {command, args} or
// null when the directory doesn't hold a usable bundle yet.
function launcherIn(dir) {
if (isWindows) {
var nodeExe = path.join(dir, 'node.exe');
var entry = path.join(dir, 'lib', 'dist', 'bin', 'codegraph.js');
if (fs.existsSync(nodeExe) && fs.existsSync(entry)) {
return { command: nodeExe, args: liftoff(entry) };
}
} else {
var launcher = path.join(dir, 'bin', 'codegraph');
if (fs.existsSync(launcher)) return { command: launcher, args: process.argv.slice(2) };
}
return null;
}
// --liftoff-only keeps tree-sitter's WASM grammars off V8's turboshaft tier to
// avoid the Zone OOM on Node >= 22 (issues #293/#298). The unix bin/codegraph
// launcher already passes it; on Windows we invoke node.exe directly so add it.
function liftoff(entry) {
return ['--liftoff-only', entry].concat(process.argv.slice(2));
}
// Download + cache the platform bundle from GitHub Releases. Returns
// {command, args}; exits the process with guidance if it can't.
async function selfHealBundle() {
var version = readVersion();
var bundlesDir = path.join(process.env.CODEGRAPH_INSTALL_DIR || path.join(os.homedir(), '.codegraph'), 'bundles');
var dest = path.join(bundlesDir, target + '-' + version);
// Already downloaded by a previous run? Use it even when downloads are
// disabled — CODEGRAPH_NO_DOWNLOAD blocks fetching, not a cached bundle.
var cached = launcherIn(dest);
if (cached) return cached;
if (process.env.CODEGRAPH_NO_DOWNLOAD) {
fail('the network fallback is disabled (CODEGRAPH_NO_DOWNLOAD is set).');
}
var asset = 'codegraph-' + target + (isWindows ? '.zip' : '.tar.gz');
var base = process.env.CODEGRAPH_DOWNLOAD_BASE || ('https://github.com/' + REPO + '/releases/download');
var url = base + '/v' + version + '/' + asset;
process.stderr.write(
'codegraph: platform bundle missing (registry did not provide ' + pkg + ').\n' +
'codegraph: downloading ' + asset + ' from GitHub Releases (' + version + ')...\n'
);
// Stage inside bundlesDir so the final rename is on the same filesystem (atomic,
// no EXDEV across tmpfs). Strip the archive's top-level codegraph-<target>/ dir.
fs.mkdirSync(bundlesDir, { recursive: true });
var stage = fs.mkdtempSync(path.join(bundlesDir, '.dl-'));
try {
var archivePath = path.join(stage, asset);
await download(url, archivePath, 6);
await verifyChecksum(archivePath, asset, base, version);
var extracted = path.join(stage, 'bundle');
fs.mkdirSync(extracted);
extract(archivePath, extracted);
var raced = launcherIn(dest); // another process may have finished meanwhile
if (raced) { rmrf(stage); return raced; }
try {
fs.renameSync(extracted, dest);
} catch (e) {
var other = launcherIn(dest); // lost the race but theirs is valid
if (other) { rmrf(stage); return other; }
throw e;
}
} catch (e) {
rmrf(stage);
fail('download failed (' + e.message + ').\n URL: ' + url);
}
rmrf(stage);
var ready = launcherIn(dest);
if (!ready) fail('downloaded bundle is missing its launcher under ' + dest + '.');
process.stderr.write('codegraph: bundle ready.\n');
return ready;
}
function readVersion() {
try {
return require(path.join(__dirname, 'package.json')).version;
} catch (e) {
fail('could not read this package\'s version to locate a matching release.');
}
}
// GET with manual redirect following (GitHub release URLs redirect to a CDN).
function download(url, dest, redirectsLeft) {
return new Promise(function (resolve, reject) {
var https = require('https');
// timeout is an idle/inactivity timeout — it won't kill a slow-but-progressing
// download, only a stalled connection (so a blocked mirror fails fast with
// guidance instead of hanging the user's command forever).
var req = https.get(url, { headers: { 'User-Agent': 'codegraph-npm-shim' }, timeout: 30000 }, function (res) {
var status = res.statusCode;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
if (redirectsLeft <= 0) { reject(new Error('too many redirects')); return; }
download(new URL(res.headers.location, url).toString(), dest, redirectsLeft - 1).then(resolve, reject);
return;
}
if (status !== 200) { res.resume(); reject(new Error('HTTP ' + status)); return; }
var file = fs.createWriteStream(dest);
res.on('error', reject);
res.pipe(file);
file.on('error', reject);
file.on('finish', function () { file.close(function () { resolve(); }); });
});
req.on('timeout', function () { req.destroy(new Error('connection timed out')); });
req.on('error', reject);
});
}
// Best-effort integrity check. When the release publishes a SHA256SUMS file, the
// downloaded archive MUST match its listed hash or we abort. When that file is
// absent (older releases) or simply unreachable, we proceed — the archive still
// arrived from GitHub over TLS. So tampering/corruption is caught, while a
// missing checksum never breaks an install.
async function verifyChecksum(archivePath, asset, base, version) {
var sumsPath = archivePath + '.SHA256SUMS';
try {
await download(base + '/v' + version + '/SHA256SUMS', sumsPath, 6);
} catch (e) {
return; // not published / unreachable → skip
}
var expected = null;
var lines = fs.readFileSync(sumsPath, 'utf8').split('\n');
for (var i = 0; i < lines.length; i++) {
var m = lines[i].trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (m && path.basename(m[2].trim()) === asset) { expected = m[1].toLowerCase(); break; }
}
if (!expected) return; // asset not listed → nothing to check
var actual = require('crypto').createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex');
if (actual !== expected) {
throw new Error('checksum mismatch for ' + asset +
' (expected ' + expected.slice(0, 12) + '…, got ' + actual.slice(0, 12) + '…)');
}
process.stderr.write('codegraph: checksum verified.\n');
}
// Extract via the system tar — present on macOS, Linux, and Windows 10+
// (bsdtar reads .zip too). No third-party dependency in the shim.
function extract(archive, destDir) {
var args = isWindows
? ['-xf', archive, '-C', destDir, '--strip-components=1']
: ['-xzf', archive, '-C', destDir, '--strip-components=1'];
var res = childProcess.spawnSync('tar', args, { stdio: 'ignore' });
if (res.error) throw new Error('tar unavailable: ' + res.error.message);
if (res.status !== 0) throw new Error('tar exited ' + res.status);
}
function rmrf(p) {
try { fs.rmSync(p, { recursive: true, force: true }); } catch (e) { /* best effort */ }
}
function fail(reason) {
process.stderr.write( process.stderr.write(
'codegraph: no prebuilt bundle for ' + target + '.\n' + 'codegraph: no prebuilt bundle for ' + target + '.\n' +
(reason ? 'codegraph: ' + reason + '\n' : '') +
'Expected the optional package ' + pkg + ' to be installed.\n' + 'Expected the optional package ' + pkg + ' to be installed.\n' +
'Try reinstalling: npm i -g @colbymchenry/codegraph\n' + 'A registry mirror (e.g. npmmirror/cnpm) that did not mirror the per-platform\n' +
'Or use the standalone installer (no Node required):\n' + 'package is the usual cause. Fixes:\n' +
' curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh\n' ' - install from the official registry:\n' +
' npm i -g @colbymchenry/codegraph --registry=https://registry.npmjs.org\n' +
' - or use the standalone installer (no Node required):\n' +
' curl -fsSL https://raw.githubusercontent.com/' + REPO + '/main/install.sh | sh\n'
); );
process.exit(1); process.exit(1);
} }
var res = childProcess.spawnSync(command, args, { stdio: 'inherit' });
if (res.error) {
process.stderr.write('codegraph: ' + res.error.message + '\n');
process.exit(1);
}
process.exit(res.status === null ? 1 : res.status);