fix(install): install.ps1 resolves the latest release without GitHub's rate-limited API (#2939)

install.ps1 resolved "latest" with one Invoke-RestMethod to the GitHub
API, with no fallback, under ErrorActionPreference Stop. The API allows
60 unauthenticated requests an hour per IP and then answers 403, so
`irm .../install.ps1 | iex` died with a raw "(403) Forbidden" on shared
networks and in CI. A pinned CODEGRAPH_VERSION=1.6.2 was used as-is, so
the download URL lacked the v that release tags carry and 404ed.
install.sh (PR #336) and `codegraph upgrade` (resolveLatestVersion)
already handle both; install.ps1 was never ported.

Fix: step 2 is now a function between Resolve-CodeGraphVersion markers.
It sends a HEAD to github.com/<repo>/releases/latest with
System.Net.HttpWebRequest and AllowAutoRedirect off, reads the tag from
the Location header with /releases/tag/([^/?#]+), falls back to the API
inside try/catch, adds the v to a bare version, and keeps the friendly
throw when both fail. Install-CodeGraphFiles is untouched (still equal to
WINDOWS_SWAP_FUNCTION).

Validation (Windows PowerShell 5.1.26100; pwsh 7 is not installed here):
- Each arm's unmodified step 2, with Invoke-RestMethod shadowed so the
  API answers a local 403: main stops with "(403) Forbidden"; the fix
  resolves v1.6.2 from the real redirect with no API call.
  CODEGRAPH_VERSION=1.6.2: main builds .../download/1.6.2/... (HEAD 404),
  the fix .../download/v1.6.2/... (HEAD 302). With the API available,
  main makes one API call and the fix none.
- New Windows-gated test runs the shipped resolver block in PowerShell
  against a local stand-in for github.com and the API: redirect read with
  one HEAD and no follow, API fallback for a 500 or a missing Location,
  the friendly error (no raw 403) when both fail, the v added to a pinned
  version with no request. 4 fail on main's install.ps1, 4 pass with the
  fix; upgrade.test.ts (the swap function pin) passes.
- Both scripts parse with no errors in PowerShell 5.1. The install steps
  were not run: they always add the install to the user PATH.

Reported in PR #560 by @hiyochi; this reimplements the fix
independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-28 00:10:27 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6162d872be
commit 760e31f2b4
3 changed files with 164 additions and 4 deletions
+1
View File
@@ -79,6 +79,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph install` and `upgrade` no longer break Codex's `config.toml` when CodeGraph's `[mcp_servers.codegraph]` entry is written differently from how CodeGraph writes it, such as indented, with spaces or quotes inside the brackets, or with Windows line endings: the entry is now updated in place, where before a second copy was added and Codex could load none of the file. A file already broken that way is repaired on the next install or upgrade, an example of the entry inside a multiline setting such as `developer_instructions` is no longer mistaken for it, uninstall removes the entry however it is written, and an entry you wrote with dotted keys or as an inline table is left as you wrote it. When your `mcp_servers` settings are one inline table, CodeGraph now says it can't add itself instead of breaking the file. Thanks @dencoseca for the report, and @danusha2345 and @rudycelekli for the first fixes. (#2562)
- For Hermes Agent, `codegraph install` and `upgrade` no longer add `mcp-codegraph` to `platform_toolsets.cli`. Hermes up to 0.22.2 reported that name as an unknown toolset on every start, and Hermes 0.22.4 reads it as the only MCP server to load, hiding your others from CLI sessions; an entry an earlier install added is taken out again. A refresh also no longer resets your own `mcp_servers.codegraph` settings, such as an absolute command path, timeouts or `enabled: false`, and a config indented or written differently from Hermes's default no longer gets a duplicate or unreadable entry. Thanks @Huy3ko for the report and @conifercone for confirming it. (#2485)
- `codegraph upgrade` now upgrades a global npm install when run from your home folder, which holds npm's global folder on Windows and with tools like nvm: before, it ran `npm install` without `-g` there, which left the global copy on the old version and put a second copy in your home folder. A copy installed in a project is now upgraded in that project wherever you run the command, with a warning if npm left it on the old version, and a copy installed with pnpm, yarn or bun gets the command that upgrades it instead of npm. Thanks @timlincool for the report and @maxmilian for the first fix. (#2477)
- The Windows installer, `irm …/install.ps1 | iex`, no longer stops with "(403) Forbidden" once GitHub's hourly limit for anonymous requests is used up, as it often is on shared networks and in CI: it now finds the latest release the way the macOS and Linux installer and `codegraph upgrade` do, and asks GitHub's API only when that fails. A version set without its `v`, like `CODEGRAPH_VERSION=1.6.2`, now installs too, where before its download failed. Thanks @hiyochi.
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
- Running `codegraph status` while `codegraph index` rebuilds a project no longer makes the rebuild fail with "database is locked": status now only reads the index, and it says when a rebuild is running in another process, in its output and as `rebuildInProgress` in `status --json`. Thanks @sealad886 for the report, and @danusha2345 and @KissFeng for the fixes. (#2913)
- Running `codegraph query`, `explore`, `node`, `callers`, `callees`, `impact`, `context`, `files` or `affected` while `codegraph index` rebuilds a project no longer makes the rebuild fail with "database is locked", and neither does the context hook Claude Code runs on its own when you ask about your code: like `codegraph status`, they now only read the index and say when a rebuild is running, and the hook stays quiet until the rebuild is done. They still answer from an index an older CodeGraph version built. Thanks @sealad886 for the report. (#2913)
+132
View File
@@ -0,0 +1,132 @@
/**
* install.ps1 resolves the version to install without the rate-limited GitHub
* API (PR #560).
*
* `irm …/install.ps1 | iex` asked only the unauthenticated GitHub API for the
* latest release. It allows 60 requests an hour per IP and then answers 403, so
* a Windows install on a shared network or in CI died with a raw "(403)
* Forbidden". A bare `CODEGRAPH_VERSION=1.6.2` built a download URL without the
* `v` that release tags carry, which 404s. install.sh (#336) and
* `codegraph upgrade` already read the release redirect first and add the `v`.
*
* These run the REAL resolver from install.ps1 — the block between its
* `Resolve-CodeGraphVersion` markers — in Windows PowerShell, pointed at a
* local stand-in for github.com and api.github.com. No request leaves the
* machine.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as http from 'http';
import type { AddressInfo } from 'net';
import * as path from 'path';
const INSTALL_PS1 = path.join(__dirname, '..', 'install.ps1');
const REPO = 'colbymchenry/codegraph';
/** The resolver exactly as install.ps1 ships it. */
function extractResolver(): string {
const text = fs.readFileSync(INSTALL_PS1, 'utf8').replace(/\r\n/g, '\n');
const m = /# >>> Resolve-CodeGraphVersion\n([\s\S]*?)# <<< Resolve-CodeGraphVersion/.exec(text);
if (!m) throw new Error('install.ps1 has no Resolve-CodeGraphVersion block');
return m[1]!;
}
/** A PowerShell single-quoted string literal. */
const psq = (s: string) => `'${s.replace(/'/g, "''")}'`;
describe.runIf(process.platform === 'win32')('install.ps1 resolves the version to install (PR #560)', () => {
let server: http.Server;
let base = '';
let web: 'redirect' | 'no-location' | 'error' = 'redirect';
let api: 'ok' | 'rate-limited' = 'ok';
const hits: string[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
hits.push(`${req.method} ${req.url}`);
if (req.url === `/${REPO}/releases/latest`) {
if (web === 'redirect') res.writeHead(302, { Location: `https://github.com/${REPO}/releases/tag/v9.8.7` });
else if (web === 'no-location') res.writeHead(200, { 'Content-Type': 'text/html' });
else res.writeHead(500);
res.end();
} else if (req.url === `/repos/${REPO}/releases/latest`) {
res.writeHead(api === 'ok' ? 200 : 403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(api === 'ok' ? { tag_name: 'v9.8.6' } : { message: 'API rate limit exceeded for 127.0.0.1.' }));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
hits.length = 0;
web = 'redirect';
api = 'ok';
});
/** Run the shipped resolver in Windows PowerShell against the stand-in (async: the stand-in runs here). */
function resolveVersion(pinned: string): Promise<{ code: number; stdout: string; stderr: string }> {
const script = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
extractResolver(),
`Resolve-CodeGraphVersion ${psq(pinned)} ${psq(REPO)} ${psq(base)} ${psq(base)}`,
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise((resolve) => {
execFile(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{ windowsHide: true, timeout: 60_000 },
(err, stdout, stderr) => {
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
resolve({ code, stdout: stdout.trim(), stderr });
},
);
});
}
it('reads the latest release from the redirect: one HEAD, not followed, no API call', async () => {
const r = await resolveVersion('');
expect(r.stderr).toBe('');
expect(r.stdout).toBe('v9.8.7');
expect(hits).toEqual([`HEAD /${REPO}/releases/latest`]);
}, 60_000);
it('falls back to the API when the redirect cannot be read', async () => {
for (const mode of ['error', 'no-location'] as const) {
web = mode;
hits.length = 0;
const r = await resolveVersion('');
expect(r.stdout, mode).toBe('v9.8.6');
expect(hits, mode).toEqual([`HEAD /${REPO}/releases/latest`, `GET /repos/${REPO}/releases/latest`]);
}
}, 60_000);
it('stops with its own message, not a raw 403, when neither answers', async () => {
web = 'error';
api = 'rate-limited';
const r = await resolveVersion('');
expect(r.code).not.toBe(0);
expect(r.stdout).toBe('');
expect(r.stderr).toContain('could not resolve the latest version');
expect(r.stderr).toContain('CODEGRAPH_VERSION');
expect(r.stderr).not.toContain('403');
}, 60_000);
it('adds the v to a bare pinned version, and asks GitHub nothing', async () => {
expect((await resolveVersion('1.6.2')).stdout).toBe('v1.6.2');
expect((await resolveVersion('v1.6.2')).stdout).toBe('v1.6.2');
expect((await resolveVersion(' 1.6.2 ')).stdout).toBe('v1.6.2');
expect(hits).toEqual([]);
}, 60_000);
});
+31 -4
View File
@@ -97,11 +97,38 @@ $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
$target = "win32-$arch"
# 2. Resolve the version (latest release unless pinned).
$version = $env:CODEGRAPH_VERSION
if (-not $version) {
$version = (Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest").tag_name
# >>> Resolve-CodeGraphVersion
function Resolve-CodeGraphVersion([string]$Pinned, [string]$Repository, [string]$WebBase = 'https://github.com', [string]$ApiBase = 'https://api.github.com') {
# "Latest" is where github.com/<repo>/releases/latest redirects to
# (.../releases/tag/vX.Y.Z), read from a HEAD that doesn't follow it, as
# install.sh and `codegraph upgrade` do. The GitHub API allows 60
# unauthenticated requests an hour per IP, then answers 403 -- routine on
# shared networks and CI (#325) -- so it is only the fallback.
$version = $Pinned
if (-not $version) {
try {
$req = [System.Net.HttpWebRequest]::Create("$WebBase/$Repository/releases/latest")
$req.Method = 'HEAD'
$req.AllowAutoRedirect = $false
$req.UserAgent = 'codegraph-install'
$req.Timeout = 15000
$resp = $req.GetResponse()
try {
if ($resp.Headers['Location'] -match '/releases/tag/([^/?#]+)') { $version = [Uri]::UnescapeDataString($Matches[1]) }
} finally { $resp.Close() }
} catch {}
}
if (-not $version) {
try { $version = (Invoke-RestMethod "$ApiBase/repos/$Repository/releases/latest").tag_name } catch {}
}
if (-not $version) { throw 'codegraph: could not resolve the latest version from GitHub; set CODEGRAPH_VERSION (e.g. v1.6.2) and run the installer again.' }
# Release tags are vX.Y.Z; accept a bare X.Y.Z too.
$version = "$version".Trim()
if ($version -notmatch '^v') { $version = "v$version" }
$version
}
if (-not $version) { throw "codegraph: could not resolve latest version; set CODEGRAPH_VERSION." }
# <<< Resolve-CodeGraphVersion
$version = Resolve-CodeGraphVersion $env:CODEGRAPH_VERSION $repo
# 3. Download the bundle and unpack it next to the stable 'current' dir (same
# volume, so moving it in is renames, not copies), then move it into 'current'.