fix(upgrade): refresh installer-written agent surfaces after a binary upgrade (#1238) (#1239)

* fix(upgrade): refresh installer-written agent surfaces after a binary upgrade

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 <noreply@anthropic.com>

* docs(installer): clarify refresh change reporting

---------

Co-authored-by: xuing <np2v9bvbbs@privaterelay.appleid.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
This commit is contained in:
XU Pengfei
2026-07-10 10:58:55 -05:00
committed by GitHub
co-authored by Claude Fable 5 xuing Colby McHenry
parent 63eb488ed4
commit 386bff0f84
7 changed files with 334 additions and 5 deletions
+84 -1
View File
@@ -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;
+106
View File
@@ -408,6 +408,112 @@ 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;
}
});
it('skips the refresh when the version probe says a stale install shadows the new one', async () => {
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
capture: () => ({ code: 0, stdout: '0.9.8\n' }), // PATH still serves the OLD version
});
const code = await runUpgrade({}, deps);
expect(code).toBe(0);
// Spawning `codegraph install --refresh` would execute the shadowed stale
// binary — the exact staleness the refresh exists to heal.
expect(calls.runs.filter((r) => r.cmd === 'codegraph')).toHaveLength(0);
expect(calls.logs.join('\n')).toMatch(/run `codegraph install --refresh` once the PATH is fixed/);
});
});
// ---------------------------------------------------------------------------
// Post-upgrade version probe — does the PATH-resolved `codegraph` serve the
// version we just installed, in THIS terminal?