Files
codegraph/__tests__/install-ps1-version.test.ts
T
Colby MchenryandClaude Opus 5.5 760e31f2b4 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>
2026-10-28 00:10:27 +00:00

133 lines
5.3 KiB
TypeScript

/**
* 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);
});