* 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:
co-authored by
Claude Fable 5
xuing
Colby McHenry
parent
63eb488ed4
commit
386bff0f84
Binary file not shown.
@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### 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`. (#1238)
|
||||
- `codegraph upgrade` on an npm install now upgrades through npm again instead of quietly creating a second copy that never wins the PATH race — previously `codegraph --version` kept reporting the old version forever, no matter how many times you upgraded. (#1238)
|
||||
- After every upgrade, CodeGraph now checks that the `codegraph` command your terminal resolves actually serves the freshly installed version — confirming you don't need a new terminal, or telling you exactly which stale install is shadowing the new one. (#1071)
|
||||
- The safety watchdog no longer kills a healthy index on severely degraded storage. It used to judge liveness purely by the event loop, so one long database write on a struggling disk looked identical to a hung process and could get a valid, in-progress index terminated. During `codegraph index`/`codegraph init` the watchdog now also checks whether the index files on disk are advancing before it acts: slow-but-progressing work is left alone (bounded by a hard cap), while a genuinely hung process is still killed exactly as fast as before. (#1231)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -2193,12 +2193,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 <id>', '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');
|
||||
@@ -2213,6 +2215,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}").`);
|
||||
|
||||
@@ -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
|
||||
* filesystem entry was created, updated, or removed; `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 created, updated, or removed by the refresh. */
|
||||
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),
|
||||
|
||||
+50
-4
@@ -389,8 +389,9 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
|
||||
// an existing Claude config, and skipped entirely by the kill-switch. Never
|
||||
// fatal to the upgrade.
|
||||
if (code === 0) {
|
||||
let probe: VersionProbe = 'inconclusive';
|
||||
try {
|
||||
reportResolvedVersion(latest, deps);
|
||||
probe = reportResolvedVersion(latest, deps);
|
||||
} catch {
|
||||
/* an inconclusive probe must not fail the upgrade */
|
||||
}
|
||||
@@ -399,6 +400,19 @@ export async function runUpgrade(opts: UpgradeOptions, deps: UpgradeDeps): Promi
|
||||
} catch {
|
||||
/* a hook-wiring hiccup must not fail the upgrade */
|
||||
}
|
||||
// The refresh executes whatever `codegraph` PATH resolves. If the probe
|
||||
// just proved that's a stale shadowed install, spawning it would rewrite
|
||||
// the agent surfaces with the very templates the refresh exists to heal —
|
||||
// skip, and point at the manual command for after the PATH is fixed.
|
||||
if (probe !== 'mismatch') {
|
||||
try {
|
||||
selfHealInstalledSurfaces(deps);
|
||||
} catch {
|
||||
/* a refresh hiccup must not fail the upgrade */
|
||||
}
|
||||
} else {
|
||||
deps.log(c.dim('Skipped refreshing agent instructions/config — run `codegraph install --refresh` once the PATH is fixed.'));
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -435,13 +449,16 @@ export function verifyResolvedVersion(latest: string, deps: UpgradeDeps): Versio
|
||||
* instead of discovering it via a mysteriously unchanged `codegraph -v`.
|
||||
* Inconclusive probes fall back to the old soft hint — never a scare on
|
||||
* setups we can't inspect (no `codegraph` on PATH yet, exotic wrappers).
|
||||
* Returns the probe result so the caller can gate the post-upgrade refresh
|
||||
* (which spawns the PATH-resolved binary) on it.
|
||||
*/
|
||||
function reportResolvedVersion(latest: string, deps: UpgradeDeps): void {
|
||||
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;
|
||||
switch (verifyResolvedVersion(latest, deps)) {
|
||||
if (method.kind === 'npm' && method.scope === 'local') return 'inconclusive';
|
||||
const probe = verifyResolvedVersion(latest, deps);
|
||||
switch (probe) {
|
||||
case 'match':
|
||||
deps.log(c.green(`✓ \`codegraph\` on your PATH now reports ${latest} — this terminal is already using it.`));
|
||||
break;
|
||||
@@ -454,6 +471,35 @@ function reportResolvedVersion(latest: string, deps: UpgradeDeps): void {
|
||||
deps.log(c.dim('Open a new terminal if `codegraph --version` looks unchanged (PATH cache).'));
|
||||
break;
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user