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:
co-authored by
Claude Opus 4.7
parent
5aae9c4bbf
commit
15072aa29f
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user