diff --git a/CHANGELOG.md b/CHANGELOG.md index e16561e30..113dacf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/__tests__/install-ps1-version.test.ts b/__tests__/install-ps1-version.test.ts new file mode 100644 index 000000000..f4c6d6b48 --- /dev/null +++ b/__tests__/install-ps1-version.test.ts @@ -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((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((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); +}); diff --git a/install.ps1 b/install.ps1 index a079205b4..d6373332e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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//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'.