diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff1a15773..51dea1511 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,13 @@ jobs: done 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 id: ver run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" @@ -58,9 +65,9 @@ jobs: TAG="v${{ steps.ver.outputs.version }}" # Idempotent: create the release once, otherwise (re-run) refresh assets. 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 - 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 - name: Publish to npm @@ -96,3 +103,19 @@ jobs: [ -n "$ok" ] || { echo "::error::$name@$V never appeared on the registry"; exit 1; } echo "verified $name@$V" 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a656e01..535b0ce9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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 ` 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 `. + 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=` 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 ### 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 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.2]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.2 [0.9.1]: https://github.com/colbymchenry/codegraph/releases/tag/v0.9.1 diff --git a/__tests__/npm-shim.test.ts b/__tests__/npm-shim.test.ts new file mode 100644 index 000000000..16e70506a --- /dev/null +++ b/__tests__/npm-shim.test.ts @@ -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) { + 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-/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((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + }, 30000); + + afterAll(() => { server?.close(); }); + + function netEnv(cache: string): Record { + 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); +}); diff --git a/package.json b/package.json index f813c1e60..5455ced92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/npm-shim.js b/scripts/npm-shim.js index 810121245..09b435e5f 100755 --- a/scripts/npm-shim.js +++ b/scripts/npm-shim.js @@ -11,48 +11,236 @@ // (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. // +// 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`: -// "bin": { "codegraph": "scripts/npm-shim.js" } +// "bin": { "codegraph": "npm-shim.js" } // with the platform packages listed in `optionalDependencies`. 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 pkg = '@colbymchenry/codegraph-' + target; var isWindows = process.platform === 'win32'; +var REPO = 'colbymchenry/codegraph'; + +main().catch(function (e) { + process.stderr.write('codegraph: ' + (e && e.message ? e.message : String(e)) + '\n'); + process.exit(1); +}); + +async function main() { + // Happy path: the npm-installed optional dependency. Fall back to a download + // when the registry didn't deliver it. + var resolved = resolveInstalledBundle() || (await selfHealBundle()); + var res = childProcess.spawnSync(resolved.command, resolved.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); +} -// On Windows the bundle's launcher is a .cmd batch file. Modern Node refuses to -// spawn .cmd/.bat directly — spawnSync throws EINVAL (the CVE-2024-27980 -// hardening, observed on Node 24). So on Windows we skip the .cmd and invoke the -// bundled node.exe against the app entry point directly. On unix the bin launcher -// is a shell script that spawns cleanly. -var command, args; -try { +// 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) { - command = require.resolve(pkg + '/node.exe'); - var entry = require.resolve(pkg + '/lib/dist/bin/codegraph.js'); - // --liftoff-only: keep tree-sitter's WASM grammars off V8's turboshaft tier - // to avoid the Zone OOM on Node >= 22 (issues #293/#298). The unix launcher - // passes this too; on Windows we invoke node.exe directly so add it here. - args = ['--liftoff-only', entry].concat(process.argv.slice(2)); + 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 { - command = require.resolve(pkg + '/bin/codegraph'); - args = process.argv.slice(2); + 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).'); } -} catch (e) { + + 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: no prebuilt bundle for ' + target + '.\n' + - 'Expected the optional package ' + pkg + ' to be installed.\n' + - 'Try reinstalling: npm i -g @colbymchenry/codegraph\n' + - 'Or use the standalone installer (no Node required):\n' + - ' curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh\n' + 'codegraph: platform bundle missing (registry did not provide ' + pkg + ').\n' + + 'codegraph: downloading ' + asset + ' from GitHub Releases (' + version + ')...\n' ); - process.exit(1); + + // Stage inside bundlesDir so the final rename is on the same filesystem (atomic, + // no EXDEV across tmpfs). Strip the archive's top-level codegraph-/ 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.'); + } } -var res = childProcess.spawnSync(command, args, { stdio: 'inherit' }); -if (res.error) { - process.stderr.write('codegraph: ' + res.error.message + '\n'); +// 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( + 'codegraph: no prebuilt bundle for ' + target + '.\n' + + (reason ? 'codegraph: ' + reason + '\n' : '') + + 'Expected the optional package ' + pkg + ' to be installed.\n' + + 'A registry mirror (e.g. npmmirror/cnpm) that did not mirror the per-platform\n' + + 'package is the usual cause. Fixes:\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(res.status === null ? 1 : res.status);