Преглед на файлове

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>
XU Pengfei преди 1 месец
родител
ревизия
386bff0f84
променени са 7 файла, в които са добавени 334 реда и са изтрити 5 реда
  1. BIN
      .kommandr/kommandr.db
  2. 1 0
      CHANGELOG.md
  3. 84 1
      __tests__/installer-targets.test.ts
  4. 106 0
      __tests__/upgrade.test.ts
  5. 33 0
      src/bin/codegraph.ts
  6. 60 0
      src/installer/index.ts
  7. 50 4
      src/upgrade/index.ts

BIN
.kommandr/kommandr.db


+ 1 - 0
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)

+ 84 - 1
__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;

+ 106 - 0
__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?

+ 33 - 0
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 <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}").`);

+ 60 - 0
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),

+ 50 - 4
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.');
+  }
 }
 
 /**