diff --git a/CHANGELOG.md b/CHANGELOG.md index 248f157d1..c5feb1e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph install`, `upgrade` and `uninstall` now write through an instructions or config file that is a symbolic link, such as a `~/.codex/AGENTS.md` or `~/.claude.json` linked from your dotfiles, instead of replacing the link with a copy: the shared file gets CodeGraph's settings, uninstall takes them out again, and the link stays in place. A link CodeGraph can't write through, like one to a read-only file or into a folder that doesn't exist, is now reported by name and left alone, and an upgrade still refreshes your other agents. Thanks @jguerra6 for the report and @danusha2345 for the first fix. (#2721) - `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) - `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) - Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332) - In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305) diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts index 403271b7f..3c9b69860 100644 --- a/__tests__/upgrade.test.ts +++ b/__tests__/upgrade.test.ts @@ -85,8 +85,8 @@ describe('detectInstallMethod', () => { it('detects a local (project) npm install as local', () => { const cwd = '/home/u/project'; const filename = `${cwd}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`; - const m = detectInstallMethod({ filename, platform: 'linux', cwd, exists: () => false }); - expect(m).toEqual({ kind: 'npm', scope: 'local' }); + const m = detectInstallMethod({ filename, platform: 'linux', cwd, exists: bundleExists(new Set([`${cwd}/package.json`])) }); + expect(m).toEqual({ kind: 'npm', scope: 'local', root: cwd }); }); it('detects an npx run from the _npx cache', () => { @@ -117,9 +117,9 @@ describe('detectInstallMethod', () => { const cwd = '/home/u/project'; const root = `${cwd}/node_modules/@colbymchenry/codegraph/node_modules/@colbymchenry/codegraph-darwin-arm64`; const filename = `${root}/lib/dist/bin/codegraph.js`; - const present = new Set([`${root}/node`, `${root}/bin/codegraph`]); + const present = new Set([`${root}/node`, `${root}/bin/codegraph`, `${cwd}/node_modules/.bin/codegraph`]); const m = detectInstallMethod({ filename, platform: 'darwin', cwd, exists: bundleExists(present) }); - expect(m).toEqual({ kind: 'npm', scope: 'local' }); + expect(m).toEqual({ kind: 'npm', scope: 'local', root: cwd }); }); it('still detects an npx run when the cached platform package has the bundle layout', () => { @@ -152,6 +152,99 @@ describe('detectInstallMethod', () => { }); expect(m.kind).toBe('unknown'); }); + + // #2477: the scope came from "is the running file under cwd", so a global + // prefix inside the home folder (Windows %APPDATA%\npm, nvm, ~/.npm-global) + // was taken for a local install whenever upgrade ran from home — npm then + // ran without -g and the global copy stayed old. It is now read from npm's + // layout: a global launcher beside the tree, or a project around it. + describe('npm scope comes from npm\'s layout, never from cwd (#2477)', () => { + const slash = (p: string | undefined) => p?.replace(/\\/g, '/'); + const winHome = 'C:/Users/X'; + const winPrefix = `${winHome}/AppData/Roaming/npm`; + const winThin = `${winPrefix}/node_modules/@colbymchenry/codegraph/node_modules/@colbymchenry/codegraph-win32-x64/lib/dist/bin/codegraph.js`; + + it.each([winHome, 'C:/Users', 'C:/', 'C:/Users/X/projects/app'])('a Windows global install under %%APPDATA%%\\npm is global with cwd %s', (cwd) => { + const present = new Set([`${winPrefix}/codegraph.cmd`, `${winHome}/package.json`]); + for (const filename of [winThin, `${winPrefix}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`]) { + expect(detectInstallMethod({ filename, platform: 'win32', cwd, exists: bundleExists(present) })).toEqual({ kind: 'npm', scope: 'global' }); + } + }); + + it.each(['/home/u', '/', '/home'])('a POSIX global install under nvm in the home folder is global with cwd %s', (cwd) => { + const prefix = '/home/u/.nvm/versions/node/v22.11.0'; + const filename = `${prefix}/lib/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`; + const present = new Set([`${prefix}/bin/codegraph`, '/home/u/package.json']); + expect(detectInstallMethod({ filename, platform: 'linux', cwd, exists: bundleExists(present) })).toEqual({ kind: 'npm', scope: 'global' }); + }); + + it('a global install under a custom prefix is global from home (~/.npm-global, D:\\tools\\npm)', () => { + const posix = '/home/u/.npm-global'; + expect(detectInstallMethod({ + filename: `${posix}/lib/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'darwin', cwd: '/home/u', exists: bundleExists(new Set([`${posix}/bin/codegraph`])), + })).toEqual({ kind: 'npm', scope: 'global' }); + expect(detectInstallMethod({ + filename: 'D:/tools/npm/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js', + platform: 'win32', cwd: 'D:/', exists: bundleExists(new Set(['D:/tools/npm/codegraph.cmd'])), + })).toEqual({ kind: 'npm', scope: 'global' }); + }); + + it('a local install whose project root is the home folder stays local and belongs to the home folder, from any cwd', () => { + const filename = `${winHome}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`; + const present = new Set([`${winHome}/package.json`, `${winHome}/node_modules/.bin/codegraph.cmd`, `${winHome}/projects/app/package.json`]); + // ~/projects/app is a project of its own: npm run there would install into it. + for (const cwd of [winHome, 'C:/', `${winHome}/projects/app`]) { + const m = detectInstallMethod({ filename, platform: 'win32', cwd, exists: bundleExists(present) }) as Extract; + expect([m.scope, slash(m.root)]).toEqual(['local', winHome]); + } + }); + + it('a local install in a project belongs to that project, from any cwd', () => { + const project = '/home/u/projects/app'; + const filename = `${project}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`; + const present = new Set([`${project}/package.json`, `${project}/node_modules/.bin/codegraph`]); + for (const cwd of [project, `${project}/src`, `${project}/packages/web`, '/home/u', '/', '/home/u/projects/application']) { + expect(detectInstallMethod({ filename, platform: 'linux', cwd, exists: bundleExists(present) })).toEqual({ kind: 'npm', scope: 'local', root: project }); + } + }); + + it('npx and bundled installs under the home folder are unaffected by a home cwd', () => { + expect(detectInstallMethod({ + filename: `${winHome}/AppData/Local/npm-cache/_npx/1a2b3c/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'win32', cwd: winHome, exists: () => false, + })).toEqual({ kind: 'npx' }); + const winBundle = `${winHome}/AppData/Local/codegraph/current`; + const win = detectInstallMethod({ + filename: `${winBundle}/lib/dist/bin/codegraph.js`, platform: 'win32', cwd: winHome, + exists: bundleExists(new Set([`${winBundle}/node.exe`, `${winBundle}/bin/codegraph.cmd`])), + }); + expect(win.kind).toBe('bundle'); + const unixBundle = '/home/u/.codegraph/versions/v1.6.2'; + expect(detectInstallMethod({ + filename: `${unixBundle}/lib/dist/bin/codegraph.js`, platform: 'linux', cwd: '/home/u', + exists: bundleExists(new Set([`${unixBundle}/node`, `${unixBundle}/bin/codegraph`, '/home/u/.codegraph'])), + })).toEqual({ kind: 'bundle', os: 'unix', bundleRoot: unixBundle, installDir: '/home/u/.codegraph' }); + }); + + it('a pnpm, yarn or bun install is named as such, so npm never runs in its folder', () => { + const pnpm = '/home/u/.local/share/pnpm/global/5'; + expect(detectInstallMethod({ + filename: `${pnpm}/node_modules/.pnpm/@colbymchenry+codegraph@1.6.2/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'linux', cwd: '/home/u', exists: bundleExists(new Set([`${pnpm}/package.json`])), + })).toEqual({ kind: 'package-manager', manager: 'pnpm', global: true }); + const yarn = '/home/u/.config/yarn/global'; + expect(detectInstallMethod({ + filename: `${yarn}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'linux', cwd: '/home/u', exists: bundleExists(new Set([`${yarn}/package.json`, `${yarn}/yarn.lock`])), + })).toEqual({ kind: 'package-manager', manager: 'yarn', global: true }); + const project = '/home/u/app'; + expect(detectInstallMethod({ + filename: `${project}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'linux', cwd: project, exists: bundleExists(new Set([`${project}/package.json`, `${project}/bun.lock`])), + })).toEqual({ kind: 'package-manager', manager: 'bun', global: false }); + }); + }); }); describe('deriveInstallDir', () => { @@ -338,7 +431,7 @@ describe('windows bundle swap script (#2185)', () => { // --------------------------------------------------------------------------- interface Calls { - runs: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }>; + runs: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv; cwd?: string }>; captures: Array<{ cmd: string; args: string[] }>; logs: string[]; errors: string[]; @@ -355,8 +448,8 @@ function makeDeps( currentVersion: overrides.currentVersion, method: overrides.method, resolveLatest: overrides.resolveLatest ?? (async () => 'v0.9.9'), - run: (cmd, args, env) => { - calls.runs.push({ cmd, args, env }); + run: (cmd, args, env, cwd) => { + calls.runs.push({ cmd, args, env, cwd }); return runExit; }, // Default probe: spawn fails → 'inconclusive'. Tests that exercise the @@ -559,6 +652,87 @@ describe('runUpgrade', () => { }); }); +describe('runUpgrade picks npm\'s scope and folder from the install, not from cwd (#2477)', () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upgrade-2477-')); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const npmRun = (calls: Calls) => calls.runs.find((r) => r.args.join(' ').includes('install')); + + it('a Windows global install run from the home folder upgrades with -g (the report)', async () => { + const home = 'C:/Users/X'; + const prefix = `${home}/AppData/Roaming/npm`; + const method = detectInstallMethod({ + filename: `${prefix}/node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js`, + platform: 'win32', + cwd: home, + exists: (p) => p.replace(/\\/g, '/') === `${prefix}/codegraph.cmd`, + }); + const { deps, calls } = makeDeps({ method, currentVersion: '0.9.8', platform: 'win32' }); + expect(await runUpgrade({}, deps)).toBe(0); + const run = npmRun(calls)!; + expect(run.args).toEqual(['/d', '/s', '/c', `npm install -g ${NPM_PACKAGE}@latest`]); + expect(run.cwd).toBeUndefined(); + }); + + it('a local install is upgraded in its own project, and the version npm left there is checked', async () => { + const project = path.join(tmp, 'app'); + const manifest = path.join(project, 'node_modules', '@colbymchenry', 'codegraph', 'package.json'); + fs.mkdirSync(path.dirname(manifest), { recursive: true }); + fs.writeFileSync(path.join(project, 'package.json'), '{}\n'); + + const method: InstallMethod = { kind: 'npm', scope: 'local', root: project }; + // npm left the project on the old version: say so, don't just claim success. + fs.writeFileSync(manifest, JSON.stringify({ version: '0.9.8' })); + const stale = makeDeps({ method, currentVersion: '0.9.8' }); + expect(await runUpgrade({}, stale.deps)).toBe(0); + expect(npmRun(stale.calls)!).toMatchObject({ cmd: 'npm', args: ['install', `${NPM_PACKAGE}@latest`], cwd: project }); + expect(stale.calls.logs.join('\n')).toContain(`Running: npm install ${NPM_PACKAGE}@latest (in ${project})`); + expect(stale.calls.logs.join('\n')).toContain(`npm finished, but ${project} still has CodeGraph v0.9.8.`); + + fs.writeFileSync(manifest, JSON.stringify({ version: '0.9.9' })); + const done = makeDeps({ method, currentVersion: '0.9.8' }); + await runUpgrade({}, done.deps); + expect(done.calls.logs.join('\n')).toContain(`✓ ${project} now has CodeGraph v0.9.9.`); + }); + + it('a local install in a home folder that is a project is upgraded there, even when run from another project inside it', async () => { + const home = path.join(tmp, 'home'); + const other = path.join(home, 'projects', 'app'); + fs.mkdirSync(path.join(home, 'node_modules', '.bin'), { recursive: true }); + fs.mkdirSync(other, { recursive: true }); + fs.writeFileSync(path.join(home, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(home, 'node_modules', '.bin', 'codegraph'), ''); + fs.writeFileSync(path.join(other, 'package.json'), '{}\n'); + const method = detectInstallMethod({ + filename: path.join(home, 'node_modules', '@colbymchenry', 'codegraph', 'dist', 'bin', 'codegraph.js'), + platform: process.platform, + cwd: other, + }); + const { deps, calls } = makeDeps({ method, currentVersion: '0.9.8' }); + expect(await runUpgrade({}, deps)).toBe(0); + expect(npmRun(calls)!.cwd).toBe(home); + }); + + it('a pnpm, yarn or bun install is not touched with npm: the user gets the command for their manager', async () => { + for (const [manager, global, command] of [ + ['pnpm', true, `pnpm add -g ${NPM_PACKAGE}@latest`], + ['yarn', true, `yarn global add ${NPM_PACKAGE}@latest`], + ['bun', false, `bun add ${NPM_PACKAGE}@latest`], + ] as const) { + const { deps, calls } = makeDeps({ method: { kind: 'package-manager', manager, global }, currentVersion: '0.9.8' }); + expect(await runUpgrade({}, deps)).toBe(1); + expect(calls.runs).toHaveLength(0); + expect(calls.logs.join('\n')).toContain(`installed with ${manager}, so npm can't upgrade it`); + expect(calls.logs.join('\n')).toContain(command); + } + }); +}); + // --------------------------------------------------------------------------- // Beta signup offer — fires ONLY after a real, successful binary update. // (The hook itself gates on TTY + the once-per-machine stored choice; see diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts index 0e088e998..e5384f973 100644 --- a/src/upgrade/index.ts +++ b/src/upgrade/index.ts @@ -8,8 +8,9 @@ * canonical installer script (single source of truth) so the download / * version-resolution / PATH logic never drifts between first-install and * upgrade. - * - **npm** — installed via `npm i -g @colbymchenry/codegraph`. Upgrading - * shells out to npm. + * - **npm** — installed via `npm i -g @colbymchenry/codegraph`, or into a + * project. Upgrading shells out to npm (for a project, in that project). + * A pnpm, yarn or bun install is named, with that manager's command. * - **npx** — ephemeral; nothing to upgrade (next `npx` fetches latest). * - **source** — a git checkout running its own `dist/`; `git pull` + rebuild. * @@ -43,8 +44,15 @@ export const INSTALL_PS1_URL = `${RAW_BASE}/install.ps1`; export type InstallMethod = | { kind: 'bundle'; os: 'unix' | 'windows'; bundleRoot: string; installDir: string | null } - | { kind: 'npm'; scope: 'global' | 'local' } + | { + kind: 'npm'; + scope: 'global' | 'local'; + /** Local only: the project folder whose node_modules holds this install, where npm runs. */ + root?: string; + } | { kind: 'npx' } + /** Installed by another package manager, which has to upgrade it (npm can't). */ + | { kind: 'package-manager'; manager: 'pnpm' | 'yarn' | 'bun'; global: boolean } | { kind: 'source'; root: string } | { kind: 'unknown'; reason: string }; @@ -52,7 +60,11 @@ export interface DetectInput { /** `__filename` of the running CLI module — `<…>/dist/bin/codegraph.js`. */ filename: string; platform: NodeJS.Platform; - cwd: string; + /** + * The folder `upgrade` runs from. Detection doesn't read it: where an + * install lives says how it was installed, from any folder (#2477). + */ + cwd?: string; /** Injectable existence probe (defaults to fs.existsSync) — for tests. */ exists?: (p: string) => boolean; } @@ -120,10 +132,31 @@ export function detectInstallMethod(input: DetectInput): InstallMethod { return { kind: 'npx' }; } - // npm install (global or local): lives under a node_modules tree. - if (norm.includes('/node_modules/')) { - const underCwd = norm.startsWith(toPosix(P.resolve(input.cwd)) + '/'); - return { kind: 'npm', scope: underCwd ? 'local' : 'global' }; + // npm install (global or local): lives under a node_modules tree. Which of + // the two is read from npm's own layout, never from cwd (#2477). A global + // prefix often sits inside the home folder — Windows `%APPDATA%\npm`, nvm, + // fnm, `~/.npm-global` — so "the running file is under cwd" took every such + // global install for a local one whenever `upgrade` ran from home: `npm + // install` without -g left the global copy stale and dropped a stray local + // one into the home folder. + const nodeModulesAt = norm.indexOf('/node_modules/'); + if (nodeModulesAt !== -1) { + // The folder holding the outermost node_modules (the same offset in + // `filename`, which `toPosix` only changes slash by slash): the global + // prefix on Windows, `/lib` on POSIX, or the project root. + let owner = input.filename.slice(0, nodeModulesAt); + if (owner === '' || /^[A-Za-z]:$/.test(owner)) owner += P.sep; // a tree at a filesystem/drive root + // pnpm, yarn and bun keep their global packages in a folder that looks + // like an npm project (a package.json beside node_modules); running npm + // there, or a second copy through npm, is never the upgrade. + const manager = otherPackageManager(norm, owner, P, exists); + if (manager) return manager; + if (isNpmGlobalTree(owner, P, isWin, exists)) return { kind: 'npm', scope: 'global' }; + // A project's install is upgraded in that project, whatever folder + // `upgrade` runs from: npm run anywhere else — outside the project, or in + // another project inside it, as in a home folder that is itself one — + // installs a second copy there and leaves this one as it was. + return { kind: 'npm', scope: 'local', root: P.resolve(owner) }; } // Bundle: /lib/dist/bin/codegraph.js → is up 3 from bin/. @@ -145,6 +178,50 @@ export function detectInstallMethod(input: DetectInput): InstallMethod { return { kind: 'unknown', reason: `unrecognized install layout at ${input.filename}` }; } +/** A pnpm, yarn or bun install, from its `.pnpm` layout or its lockfile beside node_modules. */ +function otherPackageManager( + norm: string, + owner: string, + P: path.PlatformPath, + exists: (p: string) => boolean, +): Extract | null { + const where = toPosix(owner).toLowerCase(); + if (norm.includes('/node_modules/.pnpm/') || exists(P.join(owner, 'pnpm-lock.yaml'))) { + return { kind: 'package-manager', manager: 'pnpm', global: /\/pnpm\/global\//.test(where + '/') }; + } + if (exists(P.join(owner, 'yarn.lock'))) { + return { kind: 'package-manager', manager: 'yarn', global: /\/yarn\/(?:data\/)?global$/.test(where) }; + } + if (exists(P.join(owner, 'bun.lockb')) || exists(P.join(owner, 'bun.lock'))) { + return { kind: 'package-manager', manager: 'bun', global: /\/\.bun\/install\/global$/.test(where) }; + } + return null; +} + +/** + * Whether the node_modules tree in `owner` is npm's global one, read from + * where npm put the launcher: beside a global tree — `\codegraph.cmd` + * on Windows, `/bin/codegraph` next to `/lib/node_modules` on + * POSIX — and in `node_modules/.bin` of a project folder (one with a + * package.json) for a local install. A tree with neither is taken for + * global, since no project owns it. + */ +function isNpmGlobalTree( + owner: string, + P: path.PlatformPath, + isWin: boolean, + exists: (p: string) => boolean, +): boolean { + const globalLauncher = isWin + ? exists(P.join(owner, 'codegraph.cmd')) + : P.basename(owner) === 'lib' && exists(P.join(P.dirname(owner), 'bin', 'codegraph')); + if (globalLauncher) return true; + const bin = P.join(owner, 'node_modules', '.bin'); + const local = exists(P.join(bin, 'codegraph')) || exists(P.join(bin, 'codegraph.cmd')) + || exists(P.join(owner, 'package.json')); + return !local; +} + // --------------------------------------------------------------------------- // Version helpers (pure) // --------------------------------------------------------------------------- @@ -291,7 +368,7 @@ export interface UpgradeDeps { method: InstallMethod; resolveLatest: (pin?: string) => Promise; /** Run a command inheriting stdio; returns its exit code (-1 = spawn failed). */ - run: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => number; + run: (cmd: string, args: string[], env?: NodeJS.ProcessEnv, cwd?: string) => number; /** Run a command capturing stdout (nothing reaches the terminal); null = spawn failed. */ capture: (cmd: string, args: string[]) => { code: number; stdout: string } | null; hasCommand: (cmd: string) => boolean; @@ -393,6 +470,17 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi deps.log(c.green('npx always runs the latest version on demand — nothing to upgrade.')); deps.log(c.dim(`Force a fresh fetch with: npx ${NPM_PACKAGE}@latest`)); return 0; + case 'package-manager': { + const pkg = `${NPM_PACKAGE}@${opts.version ? stripV(latest) : 'latest'}`; + const command = { + pnpm: method.global ? `pnpm add -g ${pkg}` : `pnpm add ${pkg}`, + yarn: method.global ? `yarn global add ${pkg}` : `yarn add ${pkg}`, + bun: method.global ? `bun add -g ${pkg}` : `bun add ${pkg}`, + }[method.manager]; + deps.warn(`This CodeGraph was installed with ${method.manager}, so npm can't upgrade it.`); + deps.log(c.dim(`Upgrade it with: ${command}`)); + return 1; + } case 'source': deps.warn(`Running from a source checkout at ${method.root}.`); deps.log(c.dim('Upgrade it with: git pull && npm run build')); @@ -485,8 +573,18 @@ export function verifyResolvedVersion(latest: string, deps: UpgradeDeps): Versio function reportResolvedVersion(latest: string, deps: UpgradeDeps): VersionProbe { const { method } = deps; // A project-local npm install isn't served by PATH's `codegraph` (that - // would be some other install) — a probe could only false-alarm. - if (method.kind === 'npm' && method.scope === 'local') return 'inconclusive'; + // would be some other install) — a probe could only false-alarm. Read the + // version npm left in the project instead, so a no-op install isn't + // reported as an upgrade (#2477). + if (method.kind === 'npm' && method.scope === 'local') { + const installed = method.root ? readInstalledVersion(method.root) : null; + if (installed && parseSemver(installed) && compareVersions(installed, latest) === 0) { + deps.log(c.green(`✓ ${method.root} now has CodeGraph ${latest}.`)); + } else if (installed) { + deps.warn(`npm finished, but ${method.root} still has CodeGraph ${normalizeVersion(installed)}.`); + } + return 'inconclusive'; + } const probe = verifyResolvedVersion(latest, deps); switch (probe) { case 'match': @@ -772,9 +870,12 @@ function upgradeNpm( const args = method.scope === 'global' ? ['install', '-g', `${NPM_PACKAGE}@${versionSpec}`] : ['install', `${NPM_PACKAGE}@${versionSpec}`]; - deps.log(c.dim(`Running: npm ${args.join(' ')}`)); + // A local install is upgraded in the project it belongs to: npm run from + // any other folder would install a second, stray copy there. + const where = method.scope === 'local' ? method.root : undefined; + deps.log(c.dim(`Running: npm ${args.join(' ')}${where ? ` (in ${where})` : ''}`)); const inv = npmInvocation(deps.platform, args); - const code = deps.run(inv.cmd, inv.args, process.env); + const code = deps.run(inv.cmd, inv.args, process.env, where); if (code !== 0) { deps.error(`npm exited with code ${code}.`); if (method.scope === 'global') { @@ -820,12 +921,23 @@ export function hasCommand(cmd: string): boolean { return false; } -export function defaultRun(cmd: string, args: string[], env?: NodeJS.ProcessEnv): number { - const r = spawnSync(cmd, args, { stdio: 'inherit', env: env ?? process.env, windowsHide: true }); +export function defaultRun(cmd: string, args: string[], env?: NodeJS.ProcessEnv, cwd?: string): number { + const r = spawnSync(cmd, args, { stdio: 'inherit', env: env ?? process.env, windowsHide: true, cwd }); if (r.error) return -1; return r.status ?? -1; } +/** The CodeGraph version installed in `root/node_modules`, or null. */ +function readInstalledVersion(root: string): string | null { + try { + const manifest = path.join(root, 'node_modules', ...NPM_PACKAGE.split('/'), 'package.json'); + const version: unknown = JSON.parse(fs.readFileSync(manifest, 'utf-8')).version; + return typeof version === 'string' ? version : null; + } catch { + return null; + } +} + export function defaultCapture(cmd: string, args: string[]): { code: number; stdout: string } | null { // stdio is piped (the default with `encoding`), so nothing the probed // command prints reaches the user's terminal. The timeout keeps a wedged