From d4cf687871cc7d6b3edc18f0568bbf7b695f1da2 Mon Sep 17 00:00:00 2001 From: xuing Date: Fri, 10 Jul 2026 11:26:13 +0900 Subject: [PATCH 1/2] fix(upgrade): refresh installer-written agent surfaces after a binary upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph upgrade swapped the binary but never revisited what earlier installs wrote into CLAUDE.md / AGENTS.md / GEMINI.md and the agent configs, so sections written by a pre-1.0 installer kept teaching agents a multi-tool surface (including tools that no longer exist) months of releases later. The install path already self-heals everything it owns, but nothing ever called it on upgrade. - codegraph install --refresh: non-interactive sweep that re-runs install() for already-configured targets only — never a first install; permissions and prompt-hook choices are preserved. - codegraph upgrade spawns it via the freshly-installed binary after a successful swap (the still-running old process would only rewrite its own stale template). Gated on PATH resolution and the CODEGRAPH_NO_INSTALL_REFRESH=1 kill-switch; never fatal to the upgrade. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + __tests__/installer-targets.test.ts | 85 ++++++++++++++++++++++++++- __tests__/upgrade.test.ts | 91 +++++++++++++++++++++++++++++ src/bin/codegraph.ts | 33 +++++++++++ src/installer/index.ts | 60 +++++++++++++++++++ src/upgrade/index.ts | 33 +++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d0d23d7..101698e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes +- `codegraph upgrade` now also refreshes what previous versions installed into your agents — the CodeGraph section in CLAUDE.md / AGENTS.md / GEMINI.md and the MCP entry — so upgrading no longer leaves agents following instructions written for tools that have since been renamed or removed. Refresh-only: agents you never configured are not touched, and your permission and hook choices are preserved. Also available manually as `codegraph install --refresh`, and skippable with `CODEGRAPH_NO_INSTALL_REFRESH=1`. ## [1.3.1] - 2026-07-09 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index ffa708197..e11efd3cc 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; -import { uninstallTargets } from '../src/installer'; +import { uninstallTargets, refreshTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude'; @@ -1396,6 +1396,89 @@ describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { }); }); +describe('Installer — refreshTargets sweep (codegraph install --refresh)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('rf-home'); + tmpCwd = mkTmpDir('rf-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('rewrites a stale instructions block a previous version left, and reports refreshed', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + + // Simulate the file as an old install left it: same markers, the old + // multi-tool wording. + const claudeMd = path.join(tmpHome, '.claude', 'CLAUDE.md'); + fs.writeFileSync(claudeMd, LEGACY_BLOCK + '\n'); + + const reports = refreshTargets([claude], 'global'); + expect(reports[0].status).toBe('refreshed'); + expect(reports[0].changedPaths).toContain(claudeMd); + + const md = fs.readFileSync(claudeMd, 'utf-8'); + expect(md).not.toContain('codegraph_search'); + expect(md).toContain('codegraph_explore'); + }); + + it('never performs a first install — unconfigured agents stay untouched', () => { + const reports = refreshTargets(ALL_TARGETS, 'global'); + for (const t of ALL_TARGETS) { + const r = reports.find((x) => x.id === t.id)!; + expect(r.status).toBe(t.supportsLocation('global') ? 'not-configured' : 'unsupported'); + expect(r.changedPaths).toEqual([]); + expect(t.detect('global').alreadyConfigured).toBe(false); + } + }); + + it('preserves the user\'s permission choices (refresh never writes permissions)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: true }); + + // The user has since trimmed the allowlist by hand. + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + settings.permissions.allow = []; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + refreshTargets([claude], 'global'); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after.permissions.allow).toEqual([]); + }); + + it('is idempotent — a second sweep on a current machine reports unchanged everywhere', () => { + for (const t of ALL_TARGETS) { + if (t.supportsLocation('global')) t.install('global', { autoAllow: true }); + } + const first = refreshTargets(ALL_TARGETS, 'global'); + // Fresh installs are already current, so even the first sweep may be + // all-unchanged; what matters is the second definitely is. + const second = refreshTargets(ALL_TARGETS, 'global'); + for (const r of [...first, ...second]) { + expect(['unchanged', 'refreshed']).toContain(r.status); + } + for (const r of second) { + expect(r.status).toBe('unchanged'); + expect(r.changedPaths).toEqual([]); + } + }); +}); + describe('Installer — Cursor rules file cleanup on uninstall', () => { let tmpHome: string; let tmpCwd: string; diff --git a/__tests__/upgrade.test.ts b/__tests__/upgrade.test.ts index ceef23280..38ec2c5f4 100644 --- a/__tests__/upgrade.test.ts +++ b/__tests__/upgrade.test.ts @@ -365,6 +365,97 @@ describe('runUpgrade', () => { }); }); +// --------------------------------------------------------------------------- +// Post-upgrade self-heal of installed agent surfaces +// --------------------------------------------------------------------------- + +describe('post-upgrade refresh of installed agent surfaces', () => { + it('runs `codegraph install --refresh` via the NEW binary after a successful npm upgrade', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + hasCommand: (cmd) => cmd === 'codegraph', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + // The refresh is spawned AFTER the binary swap, so the fresh install + // (with the current templates) does the writing — not this process. + const last = calls.runs[calls.runs.length - 1]; + expect(last?.cmd).toBe('codegraph'); + expect(last?.args).toEqual(['install', '--refresh']); + }); + + it('runs the Windows .cmd launcher through cmd.exe', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + platform: 'win32', + hasCommand: (cmd) => cmd === 'codegraph', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + const last = calls.runs[calls.runs.length - 1]; + expect(last?.cmd).toBe('cmd.exe'); + expect(last?.args).toEqual(['/d', '/s', '/c', 'codegraph install --refresh']); + }); + + it('skips the refresh when `codegraph` is not resolvable on PATH', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + // default hasCommand resolves only curl + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0); + }); + + it('a failing refresh warns but does not fail the upgrade', async () => { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + hasCommand: (cmd) => cmd === 'codegraph', + }); + deps.run = (cmd, args, env) => { + calls.runs.push({ cmd, args, env }); + return cmd === 'codegraph' ? 1 : 0; + }; + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.logs.join('\n')).toMatch(/install --refresh/); + }); + + it('does not run after a failed upgrade', async () => { + const { deps, calls } = makeDeps( + { + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + hasCommand: (cmd) => cmd === 'codegraph', + }, + 1 + ); + const code = await runUpgrade({}, deps); + expect(code).toBe(1); + expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0); + }); + + it('respects the CODEGRAPH_NO_INSTALL_REFRESH kill-switch', async () => { + process.env.CODEGRAPH_NO_INSTALL_REFRESH = '1'; + try { + const { deps, calls } = makeDeps({ + method: { kind: 'npm', scope: 'global' }, + currentVersion: '0.9.8', + hasCommand: (cmd) => cmd === 'codegraph', + }); + const code = await runUpgrade({}, deps); + expect(code).toBe(0); + expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0); + } finally { + delete process.env.CODEGRAPH_NO_INSTALL_REFRESH; + } + }); +}); + // --------------------------------------------------------------------------- // Re-index staleness — real index, real metadata stamp // --------------------------------------------------------------------------- diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 97448f38d..451793710 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2187,12 +2187,14 @@ program .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)') .option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)') + .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`') .action(async (opts: { target?: string; location?: string; yes?: boolean; permissions?: boolean; printConfig?: string; + refresh?: boolean; }) => { if (opts.printConfig) { const { getTarget, listTargetIds } = await import('../installer/targets/registry'); @@ -2207,6 +2209,37 @@ program return; } + // --refresh: non-interactive sweep that re-writes what previous + // installs configured (instructions section, MCP entry, legacy-hook + // cleanups) for already-configured agents, so those surfaces match + // THIS binary's templates. Skips everything else — never a first + // install, never touches permissions or the prompt hook. Sweeps both + // locations unless --location narrows it. + if (opts.refresh) { + const { refreshTargets } = await import('../installer'); + const { ALL_TARGETS } = await import('../installer/targets/registry'); + if (opts.location && opts.location !== 'global' && opts.location !== 'local') { + error(`--location must be "global" or "local" (got "${opts.location}").`); + process.exit(1); + } + const locs: Array<'global' | 'local'> = opts.location + ? [opts.location as 'global' | 'local'] + : ['global', 'local']; + let changed = 0; + for (const loc of locs) { + for (const report of refreshTargets(ALL_TARGETS, loc)) { + for (const p of report.changedPaths) { + changed += 1; + console.log(` ${report.displayName}: refreshed ${p}`); + } + } + } + if (changed === 0) { + console.log('All configured agent surfaces are already current.'); + } + return; + } + const { runInstallerWithOptions } = await import('../installer'); if (opts.location && opts.location !== 'global' && opts.location !== 'local') { error(`--location must be "global" or "local" (got "${opts.location}").`); diff --git a/src/installer/index.ts b/src/installer/index.ts index 5f703a385..54003cd9d 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -359,6 +359,66 @@ export function uninstallTargets( }); } +export type RefreshStatus = 'refreshed' | 'unchanged' | 'not-configured' | 'unsupported'; + +/** + * Per-target outcome of a refresh sweep. `refreshed` means at least one + * file's content actually changed; `unchanged` means the target was + * already current (every write reported byte-identical); the other two + * mirror `UninstallStatus`. + */ +export interface RefreshReport { + id: TargetId; + displayName: string; + location: Location; + status: RefreshStatus; + /** Absolute paths whose content actually changed. */ + changedPaths: string[]; +} + +/** + * Pure refresh sweep — re-runs `install()` for every target that is + * ALREADY configured at `location`, so the surfaces a previous version + * wrote (the marker-fenced instructions section, the MCP server entry, + * the legacy-hook cleanups) match the binary that will serve them. + * Without this, those files keep the wording — and the tool names — of + * whatever version first wrote them, no matter how many upgrades later. + * + * Strictly a refresh, never a first install: + * - targets that aren't `alreadyConfigured` are skipped untouched; + * - permissions are not written (`autoAllow: false`) and the prompt + * hook is left as-is (`promptHook: undefined`), so choices the user + * made at install time — or by hand since — are preserved. + * + * Every write underneath is the targets' own idempotent upsert, so a + * re-run on an already-current machine reports `unchanged` everywhere. + * Exposed (and unit-tested) separately from the CLI wiring, same as + * `uninstallTargets`. + */ +export function refreshTargets( + targets: readonly AgentTarget[], + location: Location, +): RefreshReport[] { + return targets.map((target) => { + const base = { id: target.id, displayName: target.displayName, location }; + if (!target.supportsLocation(location)) { + return { ...base, status: 'unsupported' as const, changedPaths: [] }; + } + if (!target.detect(location).alreadyConfigured) { + return { ...base, status: 'not-configured' as const, changedPaths: [] }; + } + const result = target.install(location, { autoAllow: false, promptHook: undefined }); + const changedPaths = result.files + .filter((f) => f.action === 'created' || f.action === 'updated' || f.action === 'removed') + .map((f) => f.path); + return { + ...base, + status: changedPaths.length > 0 ? ('refreshed' as const) : ('unchanged' as const), + changedPaths, + }; + }); +} + /** * Interactive uninstaller — the inverse of `runInstallerWithOptions`. * Asks global-vs-local first (unless `--location`/`--yes` is given), diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts index e98f5fff8..efabf141c 100644 --- a/src/upgrade/index.ts +++ b/src/upgrade/index.ts @@ -381,10 +381,43 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi } catch { /* a hook-wiring hiccup must not fail the upgrade */ } + try { + selfHealInstalledSurfaces(deps); + } catch { + /* a refresh hiccup must not fail the upgrade */ + } } return code; } +/** + * Refresh the agent surfaces previous installs wrote — the marker-fenced + * instructions sections (CLAUDE.md / AGENTS.md / GEMINI.md), MCP entries, + * legacy-hook cleanups — so they match the version that will serve them. + * Unlike the prompt hook above, this content is NOT version-agnostic: the + * templates are baked into the binary, so the still-running old process + * would only rewrite its own stale copy — the exact staleness this heals. + * We therefore spawn the freshly-installed binary (`codegraph install + * --refresh`), which is refresh-only: agents never configured stay + * untouched, and permission / prompt-hook choices are preserved. Gated on + * `codegraph` being resolvable on PATH (an npm-local install isn't) and on + * the kill-switch; never fatal to the upgrade. + */ +function selfHealInstalledSurfaces(deps: UpgradeDeps): void { + if (process.env.CODEGRAPH_NO_INSTALL_REFRESH === '1') return; + if (!deps.hasCommand('codegraph')) return; + deps.log(c.dim('Refreshing agent instruction sections and config written by previous versions…')); + // Windows installs expose codegraph through a .cmd launcher. Node cannot + // spawn .cmd files directly without a shell, so route the constant command + // through cmd.exe there (the same launcher a terminal would resolve). + const code = deps.platform === 'win32' + ? deps.run('cmd.exe', ['/d', '/s', '/c', 'codegraph install --refresh']) + : deps.run('codegraph', ['install', '--refresh']); + if (code !== 0) { + deps.warn('Could not refresh the installed agent surfaces — run `codegraph install --refresh` manually.'); + } +} + /** * Wire the Claude `UserPromptSubmit` front-load hook on upgrade for an * already-configured global Claude install. No-op when Claude isn't configured, From 7357b0499676e3a8589b967f0215bf04dd1d3b8a Mon Sep 17 00:00:00 2001 From: xuing Date: Fri, 10 Jul 2026 12:35:44 +0900 Subject: [PATCH 2/2] docs(installer): clarify refresh change reporting --- src/installer/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/installer/index.ts b/src/installer/index.ts index 54003cd9d..ab8d03aad 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -363,7 +363,7 @@ export type RefreshStatus = 'refreshed' | 'unchanged' | 'not-configured' | 'unsu /** * Per-target outcome of a refresh sweep. `refreshed` means at least one - * file's content actually changed; `unchanged` means the target was + * filesystem entry was created, updated, or removed; `unchanged` means the target was * already current (every write reported byte-identical); the other two * mirror `UninstallStatus`. */ @@ -372,7 +372,7 @@ export interface RefreshReport { displayName: string; location: Location; status: RefreshStatus; - /** Absolute paths whose content actually changed. */ + /** Absolute paths created, updated, or removed by the refresh. */ changedPaths: string[]; }