diff --git a/.kommandr/kommandr.db b/.kommandr/kommandr.db new file mode 100644 index 000000000..db7a7459c Binary files /dev/null and b/.kommandr/kommandr.db differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 522e43a0c..f38438c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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 66a4ee6c0..abb720112 100644 --- a/__tests__/upgrade.test.ts +++ b/__tests__/upgrade.test.ts @@ -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? diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 75950cc43..15d1afb66 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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 ', '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}").`); diff --git a/src/installer/index.ts b/src/installer/index.ts index 5f703a385..ab8d03aad 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 + * 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), diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts index 9fc97df3d..7ad27562d 100644 --- a/src/upgrade/index.ts +++ b/src/upgrade/index.ts @@ -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.'); + } } /**