diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d4506bd86..5bbbc9155 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -226,6 +226,28 @@ be used as the permanent corporate configuration. - Cache location: `~/.codemie/.last-update-check` - See `codemie self-update --help` for manual update options +#### Agent Version Checks + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | + +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. npm's `cafile`/`ca`/`strict-ssl` settings aren't read: behind a TLS-intercepting proxy, trust the corporate CA with `NODE_EXTRA_CA_CERTS`, or set `CODEMIE_VERSION_CHECKS_ENABLED=false`. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. When CodeMie is started through `npm run` or `npx`, npm exports the project's settings as `npm_config_*` variables, so those are ignored too and only `~/.npmrc` is read. + +With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. + +The same switch can be set in `~/.codemie/codemie-cli.config.json` (all projects) or a project's `.codemie/codemie-cli.config.json`: + +```json +{ + "workspace": { + "versionChecks": { "enabled": false } + } +} +``` + +Precedence: the env var, then the project setting, then the global one. Only an explicit `false` turns checks off. + #### Security & File Access | Variable | Description | Example | diff --git a/docs/specs/claude-version-management/installation-and-versioning.md b/docs/specs/claude-version-management/installation-and-versioning.md index be1368079..635cc10c8 100644 --- a/docs/specs/claude-version-management/installation-and-versioning.md +++ b/docs/specs/claude-version-management/installation-and-versioning.md @@ -1,5 +1,13 @@ # Claude Code CLI Installation and Version Management +> **Superseded in part (EPMCDME-14767).** The hand-maintained "supported version" described below +> is no longer the source of truth. Claude, Codex, Gemini and Kimi now track their latest npm +> release live (cached for 24h), behind the global `versionChecks.enabled` toggle. The metadata +> `supportedVersion` only marks an agent as version-checked and is never shown as current: when the +> lookup fails or checks are off, the tracked version is unknown and `install --supported` installs +> the latest release. `minimumSupportedVersion` stays hand-maintained and still blocks launch. See +> "Agent Version Checks" in `docs/CONFIGURATION.md`. The installation flow below is unchanged. + ## Specification Summary **Last Updated**: 2026-01-29 diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md new file mode 100644 index 000000000..bf9b3483b --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md @@ -0,0 +1,384 @@ +# Smarter Agent Version Recommendations (EPMCDME-14767) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the five allowlisted agents' (Claude, Codex, Gemini, Kimi, Copilot CLI) hand-edited `supportedVersion` constants with a live, 24h-cached npm lookup; unify `checkVersionCompatibility()` and `checkAgentForUpdate()` onto that single accessor; add one global fail-safe-enabled toggle gating all three flows; reword the two "verified" UI strings. + +**Architecture:** A new `getCachedLatestVersion()` (npm-view wrapper + JSON TTL cache) backs a new `resolveSupportedVersion()` accessor keyed on an explicit agent-name allowlist. `BaseAgentAdapter.checkVersionCompatibility()` (already `async`) and every `'supported'`-keyword `installVersion()` implementation call the accessor instead of reading `metadata.supportedVersion` directly; `metadata.supportedVersion` itself stays a static per-plugin constant and becomes the accessor's fallback-of-last-resort. `checkAgentForUpdate()` drops Claude's special case and routes all five allowlisted agents' "latest" lookup through the same accessor. A single `workspace.versionChecks.enabled` config field (existing `ConfigLoader` priority chain, `metrics.enabled` precedent) gates the accessor's live path; when off or on any fetch failure, the accessor returns the static fallback with zero network I/O. `codemie doctor --refresh-versions` and `codemie update --force-refresh` bypass only the cache's TTL by deleting the cache file before checks run — no signature changes needed on `HealthCheck`/`AgentAdapter` for this. + +**Tech Stack:** TypeScript, Node.js, existing `npm view` wrapper (`getLatestVersion`), `ConfigLoader`/`WorkspaceConfig`, Vitest (no new tests per repo policy — see Global Constraints). + +**Spec:** `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` + +## Global Constraints + +- Commit per task using the repository's existing convention. +- No new tests are written for this ticket (repo policy: tests only on explicit request) — every task below is `Test-first: no`. +- `minimumSupportedVersion` / `isBelowMinimum` / `blockIfBelowMinimum` stay hardcoded and untouched — no task may edit these. +- opencode and pi agents are never touched. +- Live-tracking allowlist is exactly `['claude', 'codex', 'gemini', 'kimi', 'copilot-cli']`, checked by explicit agent name — never by `npmPackage` presence. `claude-acp` and any other plugin fall straight through to the static fallback. +- No per-agent toggle — one global `workspace.versionChecks.enabled` switch only. +- An invalid/unrecognized stored value for the toggle (env var or config field) must resolve to "checks enabled" — fail-safe, the inverse of the existing `CODEMIE_DEBUG === 'true'` fail-closed convention. +- Only two UI strings change wording: `update.ts:289` and `setup.ts:783`. `AgentsCheck.ts`'s "CodeMie recommends v..." string is already correct and must not change. + +--- + +### Task 1: Global `versionChecks` config toggle + +**Files:** +- Modify: `src/env/types.ts:105-144` (`WorkspaceConfig` interface) +- Modify: `src/utils/config.ts:562-572` (`WORKSPACE_KEYS`), `src/utils/config.ts:412-432` (`loadFromEnv()`) + +**Interfaces:** +- Produces: `WorkspaceConfig.versionChecks?: { enabled?: boolean }`, read anywhere via `(await ConfigLoader.load()).versionChecks?.enabled`. Env var `CODEMIE_VERSION_CHECKS_ENABLED`. + +- [ ] **Step 1: Add the field** + + Add `versionChecks?: { enabled?: boolean };` to `WorkspaceConfig` (`src/env/types.ts`), next to the existing `metrics` field, with a one-line doc comment noting the fail-safe default (`true` unless explicitly `false`). + +- [ ] **Step 2: Register it as a workspace-scoped key** + + Add `'versionChecks'` to the `WORKSPACE_KEYS` array (`src/utils/config.ts:562-572`) — same whole-object-override treatment as `'metrics'`. + +- [ ] **Step 3: Read the env var fail-safe** + + In `loadFromEnv()` (`src/utils/config.ts:412-432`), add: when `process.env.CODEMIE_VERSION_CHECKS_ENABLED !== undefined`, set `env.versionChecks = { enabled: process.env.CODEMIE_VERSION_CHECKS_ENABLED !== 'false' }` — only the literal string `'false'` disables; anything else enables. + +- [ ] **Step 4: Commit** + + `git add src/env/types.ts src/utils/config.ts && git commit -m "feat(config): add global versionChecks.enabled toggle"` + +**Test-first: no** — config plumbing, no new tests per repo policy. + +--- + +### Task 2: Export `extractVersion` as a shared utility + +**Files:** +- Modify: `src/utils/version-utils.ts` (add export) +- Modify: `src/cli/commands/update.ts:37-40` (remove local copy, import instead) + +**Interfaces:** +- Produces: `extractVersion(versionString: string): string | null` from `src/utils/version-utils.ts` — needed by both `update.ts` (already has it, locally) and the new `version-resolution.ts` (Task 4). + +- [ ] **Step 1: Move the function** + + Copy the existing `extractVersion()` body from `update.ts:37-40` into `src/utils/version-utils.ts` verbatim, exported. + +- [ ] **Step 2: Update the caller** + + In `update.ts`, delete the local `extractVersion` definition (lines 37-40) and import it from `../../utils/version-utils.js` instead (same import line as `compareVersions`/`isValidSemanticVersion`). + +- [ ] **Step 3: Commit** + + `git add src/utils/version-utils.ts src/cli/commands/update.ts && git commit -m "refactor(version-utils): share extractVersion across callers"` + +**Test-first: no** + +--- + +### Task 3: Version cache module + +**Files:** +- Create: `src/utils/version-cache.ts` + +**Interfaces:** +- Consumes: `getLatestVersion(packageName, options)` from `src/utils/processes.ts:315`; `getCodemiePath()` from `src/utils/paths.ts`. +- Produces: `getCachedLatestVersion(packageName: string): Promise` and `clearVersionCache(): Promise<{ removed: number }>`, both used by Task 4's `resolveSupportedVersion()` and Tasks 8/9's force-refresh flags. + +- [ ] **Step 1: Write the module** + +```typescript +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { logger } from './logger.js'; +import { getCodemiePath } from './paths.js'; +import { getLatestVersion } from './processes.js'; + +const TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 3000; // keeps a stale/first-run lookup from stalling agent startup + +interface CacheEntry { version: string; fetchedAt: string; } +interface CacheFile { version: 1; packages: Record; } + +const filePath = (): string => getCodemiePath('version-cache.json'); +const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); + +async function loadCache(): Promise { + try { + const content = await fs.readFile(filePath(), 'utf-8'); + const parsed = JSON.parse(content) as unknown; + if (typeof parsed === 'object' && parsed !== null && typeof (parsed as CacheFile).packages === 'object') { + return parsed as CacheFile; + } + return emptyCache(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return emptyCache(); + logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { error: String(error) }); + return emptyCache(); + } +} + +async function saveCache(cache: CacheFile): Promise { + const file = filePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); +} + +export async function getCachedLatestVersion(packageName: string): Promise { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const isFresh = entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; + if (isFresh) return entry.version; + + try { + const live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); + if (!live) return entry?.version ?? null; + cache.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; + await saveCache(cache); + return live; + } catch (error) { + logger.debug('[version-cache] live lookup failed, using stale cache if present', { + packageName, + error: String(error), + }); + return entry?.version ?? null; + } +} + +export async function clearVersionCache(): Promise<{ removed: number }> { + const file = filePath(); + const cache = await loadCache(); + const removed = Object.keys(cache.packages).length; + try { + await fs.unlink(file); + return { removed }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { removed: 0 }; + logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); + return { removed: 0 }; + } +} +``` + + This mirrors `version-warnings.ts`'s file-store shape (`{version, }`, ENOENT-tolerant load, best-effort save). Concurrent-CLI-invocation last-write-wins is acceptable per spec's Open Risks. + +- [ ] **Step 2: Commit** + + `git add src/utils/version-cache.ts && git commit -m "feat(version-cache): add 24h TTL npm-lookup cache"` + +**Test-first: no** + +--- + +### Task 4: `resolveSupportedVersion()` accessor and live-tracked allowlist + +**Files:** +- Create: `src/agents/core/version-resolution.ts` + +**Interfaces:** +- Consumes: `getCachedLatestVersion` (Task 3), `ConfigLoader.load()` (`src/utils/config.ts`), `extractVersion` (Task 2), `logger` (`src/utils/logger.ts`). +- Produces: `LIVE_TRACKED_AGENT_NAMES`, `isLiveTrackedAgent(agentName: string): boolean`, `resolveSupportedVersion(input: ResolveSupportedVersionInput): Promise` — consumed by Tasks 5, 6, 7, 8. + +- [ ] **Step 1: Write the module** + +```typescript +import { getCachedLatestVersion } from '../../utils/version-cache.js'; +import { extractVersion } from '../../utils/version-utils.js'; +import { ConfigLoader } from '../../utils/config.js'; +import { logger } from '../../utils/logger.js'; + +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'copilot-cli'] as const; + +export function isLiveTrackedAgent(agentName: string): boolean { + return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); +} + +export interface ResolveSupportedVersionInput { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; +} + +export async function resolveSupportedVersion( + input: ResolveSupportedVersionInput +): Promise { + const { agentName, npmPackage, fallbackSupportedVersion } = input; + + if (!isLiveTrackedAgent(agentName) || !npmPackage) { + return fallbackSupportedVersion; + } + + let enabled = true; + try { + const config = await ConfigLoader.load(); + enabled = config.versionChecks?.enabled !== false; // fail-safe: only explicit `false` disables + } catch (error) { + logger.debug('[resolveSupportedVersion] config load failed, defaulting to enabled', { error: String(error) }); + } + if (!enabled) { + return fallbackSupportedVersion; + } + + try { + const live = await getCachedLatestVersion(npmPackage); + const extracted = live ? extractVersion(live) : null; + return extracted ?? fallbackSupportedVersion; + } catch (error) { + logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); + return fallbackSupportedVersion; + } +} +``` + + This is exactly Design §2's four-step decision (allowlist check → toggle check → cache lookup → fallback-on-failure) from the spec, and satisfies the fail-safe requirement from Task 1 by construction (`!== false`). + +- [ ] **Step 2: Commit** + + `git add src/agents/core/version-resolution.ts && git commit -m "feat(agents): add resolveSupportedVersion live-tracking accessor"` + +**Test-first: no** + +--- + +### Task 5: Wire `BaseAgentAdapter` to the accessor + +**Files:** +- Modify: `src/agents/core/BaseAgentAdapter.ts:284-319` (`checkVersionCompatibility`), `src/agents/core/BaseAgentAdapter.ts:180-199` (`installVersion`, default impl used by Codex/Gemini/Copilot-cli) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). +- Produces: `checkVersionCompatibility()`'s returned `supportedVersion` field is now the live-resolved value for allowlisted agents; downstream callers (`update.ts`, `setup.ts`, `install.ts`, `AgentsCheck.ts`) are unaffected in signature — none needed changing, since `checkVersionCompatibility()` was already `async`/awaited everywhere. + +- [ ] **Step 1: Resolve the recommended version live** + + In `checkVersionCompatibility()` (`BaseAgentAdapter.ts:285`), replace `const supportedVersion = this.metadata.supportedVersion || 'latest';` with a call to `resolveSupportedVersion({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion })`, then `const supportedVersion = resolved || 'latest';`. Update the `if (!this.metadata.supportedVersion)` guard at line 309 to `if (!resolved)` — same semantics for agents with no fallback defined, correct for allowlisted agents whose live value is now the source of truth. + +- [ ] **Step 2: Resolve the `'supported'` install keyword live** + + In `installVersion()` (`BaseAgentAdapter.ts:186-196`), the `version === 'supported'` branch currently reads `this.metadata.supportedVersion` directly. Replace with the same `resolveSupportedVersion(...)` call as Step 1 so `codemie install --supported` installs the version `checkVersionCompatibility()` actually displayed, not a stale static constant. Keep the existing "throw if nothing resolved" guard, now checking the resolved value instead of `this.metadata.supportedVersion`. + +- [ ] **Step 3: Commit** + + `git add src/agents/core/BaseAgentAdapter.ts && git commit -m "feat(agents): resolve supportedVersion live in BaseAgentAdapter"` + +**Test-first: no** + +--- + +### Task 6: Wire Claude plugin's `'supported'` install resolution + +**Files:** +- Modify: `src/agents/plugins/claude/claude.plugin.ts:605-622` (`installVersion` override) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). + +- [ ] **Step 1: Resolve live in the override** + + Same change as Task 5 Step 2, applied to Claude's own `installVersion()` override (it doesn't call the base implementation): the `version === 'supported'` branch (lines 610-617) currently sets `resolvedVersion = metadata.supportedVersion`. Replace with `resolvedVersion = await resolveSupportedVersion({ agentName: metadata.name, npmPackage: metadata.npmPackage, fallbackSupportedVersion: metadata.supportedVersion })`, keeping the existing throw-if-undefined guard. This is also what fixes `update.ts`'s `updateAgent()` Claude branch (`installVersion('supported')`, `update.ts:212-213`, unchanged) so the installed version matches what `checkAgentForUpdate()` reported as available. + +- [ ] **Step 2: Commit** + + `git add src/agents/plugins/claude/claude.plugin.ts && git commit -m "feat(claude): resolve --supported install version live"` + +**Test-first: no** + +--- + +### Task 7: Wire Kimi plugin's `'supported'` install resolution + +**Files:** +- Modify: `src/agents/plugins/kimi/kimi.plugin.ts:336-356` (`installVersion` override) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). + +- [ ] **Step 1: Resolve live in the override** + + Same change as Task 6, applied to Kimi's `installVersion()` override: the `version === 'supported'` branch (lines 339-346) currently sets `resolvedVersion = this.metadata.supportedVersion`. Replace with the live-resolved value via `resolveSupportedVersion(...)`, same guard pattern. The `'npm'|'latest'|'stable'` branch (351-356) is untouched. + +- [ ] **Step 2: Commit** + + `git add src/agents/plugins/kimi/kimi.plugin.ts && git commit -m "feat(kimi): resolve --supported install version live"` + +**Test-first: no** + +--- + +### Task 8: Unify `checkAgentForUpdate()`, drop the Claude special case, add `--force-refresh` + +**Files:** +- Modify: `src/cli/commands/update.ts:45-141` (`checkAgentForUpdate`), `src/cli/commands/update.ts:286-292` (already-up-to-date message), `src/cli/commands/update.ts:238-252` (command options/action) + +**Interfaces:** +- Consumes: `isLiveTrackedAgent`, `resolveSupportedVersion` (Task 4); `clearVersionCache` (Task 3). + +- [ ] **Step 1: Delete the Claude special case** + + Remove the `if (agent.name === 'claude' && agent.checkVersionCompatibility) { ... }` block (`update.ts:58-79`) entirely. Claude has `metadata.npmPackage` set, so it now falls through to the standard npm-based-agents branch below. + +- [ ] **Step 2: Route the five allowlisted agents' "latest" lookup through the accessor** + + In the standard npm-based-agents branch (`update.ts:108-140`), replace the unconditional `const latestVersion = await npm.getLatestVersion(npmPackage);` with: if `isLiveTrackedAgent(agent.name)`, call `resolveSupportedVersion({ agentName: agent.name, npmPackage, fallbackSupportedVersion: agent.metadata.supportedVersion })`; otherwise keep the existing direct `npm.getLatestVersion(npmPackage)` call unchanged (covers opencode/pi and any other manageable npm agent, per Non-goals). The rest of the function (`extractVersion`, `compareVersions`, return shape) is unchanged. + +- [ ] **Step 3: Collapse the "already up to date" message** + + Replace the `if (agent.name === 'claude') { ... } else { ... }` split at `update.ts:287-292` with the single non-Claude message unconditionally: `` spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); `` — Claude no longer needs distinct "verified" wording since it now goes through the same uniform check. + +- [ ] **Step 4: Add `--force-refresh`** + + Add `.option('-f, --force-refresh', 'Bypass the 24h version cache and re-check npm')` to the `update` command (`update.ts:238-242`). At the top of the action handler, when `options?.forceRefresh` is set, `await clearVersionCache()` before either the single-agent or check-all-agents path runs. (When `versionChecks.enabled` is `false`, `resolveSupportedVersion()` never reads the cache regardless, so this is naturally a no-op per spec — no extra gating needed.) + +- [ ] **Step 5: Commit** + + `git add src/cli/commands/update.ts && git commit -m "feat(update): unify version checks across all allowlisted agents"` + +**Test-first: no** + +--- + +### Task 9: Reword `setup.ts`'s "verified" string + +**Files:** +- Modify: `src/cli/commands/setup.ts:783` + +- [ ] **Step 1: Reword** + + Change `` console.log(chalk.yellow(` CodeMie has only tested and verified v${compat.supportedVersion}`)); `` to `` console.log(chalk.yellow(` A newer version is available: v${compat.supportedVersion}`)); ``. No other change — `checkAndInstallClaude()`'s existing `await claude.checkVersionCompatibility()` (already inside a 3s race-timeout guard, `setup.ts:772-777`) picks up the live-resolved value automatically via Task 5. + +- [ ] **Step 2: Commit** + + `git add src/cli/commands/setup.ts && git commit -m "fix(setup): reword Claude version copy to newer-version framing"` + +**Test-first: no** + +--- + +### Task 10: `codemie doctor --refresh-versions` + +**Files:** +- Modify: `src/cli/commands/doctor/index.ts:31-39` + +**Interfaces:** +- Consumes: `clearVersionCache` (Task 3). + +- [ ] **Step 1: Add the flag** + + Add `.option('--refresh-versions', 'Force a fresh agent version check (bypasses the 24h cache)')` alongside the existing `--reset-version-warnings` option (`doctor/index.ts:34`). In the action handler, when `options.refreshVersions` is set, call `await clearVersionCache()` and log a one-line confirmation (`Cleared version cache — N entries removed.`), mirroring the existing `--reset-version-warnings` block (`doctor/index.ts:36-38`) immediately above/below it. `AgentsCheck.buildDetail()` (`doctor/checks/AgentsCheck.ts:37-68`) needs no change — it already calls `agent.checkVersionCompatibility()`, which now transparently re-fetches once the cache file is gone. + +- [ ] **Step 2: Commit** + + `git add src/cli/commands/doctor/index.ts && git commit -m "feat(doctor): add --refresh-versions to bypass the version cache"` + +**Test-first: no** + +--- + +## Self-Review Notes + +- **Spec coverage:** Design §1 → Task 3. §2 → Tasks 4, 5, 6, 7, 8. §3 → Task 1 (+ fail-safe read in Task 4). §4 (notice-dedup) → no code change needed, confirmed no task touches `version-warnings.ts`. §5 (UI copy) → Tasks 8 Step 3, 9. Force-refresh (doctor/update) → Tasks 3, 8 Step 4, 10. +- **Negative constraints:** `minimumSupportedVersion`/`isBelowMinimum`/`blockIfBelowMinimum` — no task edits `BaseAgentAdapter.ts:472-` or any minimum-version constant. opencode/pi — never referenced by any task. Structural-vs-named allowlist — `isLiveTrackedAgent()` (Task 4) checks agent name, never `npmPackage` presence. No per-agent toggle — single `workspace.versionChecks.enabled` field (Task 1), no per-plugin field added. Fail-safe default — env var only disables on literal `'false'` (Task 1), config read only disables on literal `false` (Task 4). UI copy — exactly two strings reworded (Tasks 8, 9); `AgentsCheck.ts` explicitly left untouched (Task 10 note). No new tests — every task is `Test-first: no`. +- **Type consistency:** `resolveSupportedVersion(input: ResolveSupportedVersionInput): Promise` (Task 4) is the single signature reused verbatim by Tasks 5, 6, 7, 8 — same field names (`agentName`, `npmPackage`, `fallbackSupportedVersion`) throughout. `getCachedLatestVersion`/`clearVersionCache` (Task 3) signatures match their call sites in Tasks 4, 8, 10. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md new file mode 100644 index 000000000..327284847 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -0,0 +1,228 @@ +# Spec: Smarter Agent Version Recommendations (EPMCDME-14767) + +## Problem + +`supportedVersion` for Claude, Codex, Gemini, Kimi, and Copilot CLI is a hand-edited constant per +plugin (`claude.plugin.ts:39`, `codex.plugin.ts:73`, `gemini.plugin.ts:16`, `kimi.plugin.ts:26`, +`copilot-cli.plugin.ts:27-28`) that goes stale between manual bumps. Two separate code paths decide +"is this current?" — `checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`, pure local compare) +and `checkAgentForUpdate()` (`update.ts:45`, queries npm for most agents but special-cases Claude by +copying the hardcoded constant instead of checking, `update.ts:58-79`). This replaces the hardcoded +"recommended" value with a live npm-tracked one, unifies the two paths, and adds a global kill +switch. + +## Scope + +**Explicit named allowlist of the ticket's four agents** — Claude, Codex, Gemini, Kimi (plus Kimi +ACP, the same binary) — all sharing the hardcoded-constant pattern (`_SUPPORTED_VERSION` / +`_MINIMUM_SUPPORTED_VERSION`). Copilot CLI was verified below but, per PR #576 review, is left +out: it isn't one of the ticket's agents, so it keeps its maintainer-pinned version, unchanged. + +- Claude (`@anthropic-ai/claude-code`) — npm/upstream lockstep verified. +- Kimi (`@moonshot-ai/kimi-code`) — npm/upstream lockstep verified. +- Gemini (`@google/gemini-cli`) — npm/upstream lockstep verified live (`npm view` → `0.60.0`, + matches GitHub's stable tag; nightly pre-releases are not returned by npm's `latest` dist-tag). +- Codex (`codex.plugin.ts:73`, npmPackage `@openai/codex`) — verified: npm `latest` (`0.155.1`) + structurally excludes GitHub's heavy alpha pre-release stream (`0.157.0-alpha.x`, ahead of npm by + design) via npm's semver pre-release-tag exclusion from the `latest` dist-tag — a mechanical + guarantee, not an empirical match like the other four. Safe to use as source of truth for the same + reason, not merely by analogy. +- Copilot CLI (`copilot-cli.plugin.ts:27-28`, npmPackage `@github/copilot`, has a real `install()` + method) — npm/upstream lockstep verified live (`npm view` → `1.0.87`, matches GitHub's latest + release tag `v1.0.87` exactly). + +Kimi ACP needs its own allowlist entry: the allowlist is keyed by agent name and Kimi ACP is named +`kimi-acp`, though it inherits `KimiPluginMetadata` and runs the same binary. Claude ACP +(`claude-acp.plugin.ts`) is explicitly **not** in scope — see Design §2 for why. + +## Design + +> **Revised 2026-09-28 after PR #576 review.** The original design fell back to the hardcoded +> constant whenever the live value was unavailable. That contradicted ticket criteria #4 ("no stale +> value shown as current") and #5 ("checks off → as if no supported version were configured"), so +> the sections below now describe the implemented behavior: an unknown tracked version is reported +> as unknown, and every passive consumer stays silent instead of comparing against the constant. + +### 1. Version cache module + +New module `src/utils/version-cache.ts` exposing `getCachedLatestVersion(packageName): Promise`. Persists `{ version: 1, packages: { '|': { version, fetchedAt } }, +failures: { '|': failedAt } }` to a new JSON file under `~/.codemie/` (sibling +to `version-warnings.json`, not part of the `ConfigLoader` schema). The registry id is the resolved +registry URL's origin without userinfo, plus `#` and a SHA-256 of the full resolved URL, so the file +never contains credentials or tokens from the registry URL; entries in the older raw-URL key format +are dropped on load, so the next write removes them from disk. TTL is 24h from +`fetchedAt` (a `fetchedAt` in the future counts as stale). On a miss it reads the package's `latest` +version from the npm registry (`src/utils/npm-registry.ts`). A failed lookup (timeout, network, +non-200, or a response that isn't a version string) returns `null`, never the expired entry, and is +logged with `logger.warn` (log file only). The failure time is recorded, and lookups for that package +are skipped (returning `null`) for 10 minutes, so an offline machine doesn't wait the full timeout on +every launch; a later success clears it. The expired version itself is never served. A failed +cache write still returns the fetched value; malformed or torn cache files read as empty, and the next +successful write replaces them. A `bypassCache` option skips a fresh entry or a recent failure and always fetches (still +writing the result back); `codemie update` uses it, because the user explicitly asked to check now. + +The registry is queried directly (one HTTPS GET of `//latest`, 3s limit) rather than +by spawning `npm view`: measured on a Windows laptop, `npm view` took 2.5–3.8s per package and ~4s +each when run in parallel, so the original 3s limit was routinely exceeded and the feature silently did +nothing. The direct request takes well under a second. It honors npm's `registry`, `@scope:registry`, +`https-proxy`/`proxy` and `noproxy` settings from the user `.npmrc`, plus `npm_config_*` env vars for +the unscoped settings (`@scope:registry` comes from the user `.npmrc` only). When CodeMie was launched +by npm (`npm run`/`npx`), npm exports the project's `.npmrc` into `npm_config_*`, so the env vars — +`npm_config_userconfig` included — are ignored and only `~/.npmrc` is read; +without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy / +PAC. A project `.npmrc` is deliberately **not** read: the result is cached globally for 24h, so a +checked-out repo that could pick the registry or proxy could plant an old release as the tracked +version for every project (or route env secrets via `${VAR}` to a host of its choosing). Registries +that require authentication aren't supported; those lookups fail safely. + +### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist + +The plugins' hardcoded constants (`CLAUDE_SUPPORTED_VERSION`, `CODEX_SUPPORTED_VERSION`, +`GEMINI_SUPPORTED_VERSION`, `KIMI_SUPPORTED_VERSION`) stay in the source as the fallback of last +resort. One shared accessor, `resolveSupportedVersionDetailed()` in +`src/agents/core/version-resolution.ts`, is the single place both `checkVersionCompatibility()` and +`checkAgentForUpdate()` read from. It returns `{ version, isCurrent }`: + +1. With the global toggle (Section 3) off, nothing is current, for any agent, and there's no network + I/O. +2. Agents are matched by an **explicit named allowlist** (agent name, not a structural check such as + "does `metadata.npmPackage` exist"): `claude`, `codex`, `gemini`, `kimi` and `kimi-acp`. + `claude-acp` is not: its `getVersion()` returns `null`, so it never takes part in version + comparison. +3. For an allowlisted agent the version cache is consulted for its npm package, extracting the version + with the existing `extractVersion()` convention. Only a successful lookup is current. A failed + lookup or a prerelease value returns the fallback with `isCurrent: false`. So does a registry + `latest` below the agent's `minimumSupportedVersion` (a lagging mirror or a mis-set dist-tag), + which the result also flags as `liveBelowMinimum` together with that `registryLatestVersion`. +4. Any other agent with a pinned version (e.g. Copilot CLI) keeps it as current, exactly as before. + +`checkVersionCompatibility()` exposes `isCurrent` as `versionKnown`. When it is `false`, the result +reports `supportedVersion: 'latest'`, `compatible: true`, and no update. The launch notice, `codemie +doctor`, `codemie setup` and `codemie update` then behave as if no supported version were configured. +The `minimumSupportedVersion` gate is computed independently and still applies in every case. +`installVersion('supported')` (`resolveSupportedInstallVersion()`) installs the current version, or +the `latest` channel when it is unknown — never the stale constant, which can be far behind upstream. +When the registry `latest` is below the minimum, the tracked version is unknown at launch (no +notice), and any install of the tracked version stops with an `AgentInstallationError` naming the +registry latest and the minimum and pointing at `codemie install `: the `latest` +channel would install the very release the minimum gate refuses. `checkVersionCompatibility()` +passes `liveBelowMinimum`/`registryLatestVersion` through, so `codemie install --supported` and the +plain `codemie install claude|codex` default stop with that error instead of installing `latest`. +`run()` resolves compatibility once and shares it between the minimum gate and the notice. + +`checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`) becomes async and calls this accessor +instead of reading `this.metadata.supportedVersion` directly; its callers (`run()`'s startup warning, +`install.ts`, `update.ts`, `AgentsCheck.ts`, `setup.ts`'s `checkAndInstallClaude`) are updated to +await it. `checkAgentForUpdate()`'s Claude special-case (`update.ts:58-79`) is deleted — Claude now +goes through the same uniform path as the other allowlisted agents, via the same accessor. + +### 3. Global toggle + +New nested boolean on `WorkspaceConfig`, following the existing `metrics.enabled` precedent — +`workspace.versionChecks.enabled` (default `true`), stored in `~/.codemie/codemie-cli.config.json` / +`.codemie/codemie-cli.config.json`, env var `CODEMIE_VERSION_CHECKS_ENABLED`. It is resolved field by +field — env var, then project, then global — not through `ConfigLoader.load()`. `load()` swaps in a +project's whole `workspace` block (which would hide a global setting the project doesn't repeat) and +throws when no profile is active (which would hide the env var). Both the env var and the config +value resolve **fail-safe**: any value other than an explicit, recognized "disable" (literal `false` +for the config field, `'false'` for the env var) resolves to enabled — the deliberate inverse of the +`CODEMIE_DEBUG === 'true'` fail-closed convention, because an invalid or unrecognized stored value +must never silently disable checks. + +When disabled there are no network calls from any gated flow: +- **Launch notice:** silent. +- **`codemie setup`:** shows a plain "installed" line; a missing Claude is still offered for install + (a missing-agent prompt, not a version check), with neutral copy. +- **`codemie doctor`:** no "tracking vX" warning. +- **`codemie update`:** skips these agents, with a dim "version checks are disabled" note instead of + "Could not check". +- **Minimum-version block:** unchanged. The ticket keeps it "as-is" and scopes this story to the + recommended/supported advisory only. + +### 4. Notice-dedup interaction + +`VersionWarningStore` keeps keying its one-time notice on the resolved `supportedVersion` string, +unchanged. Because the resolver only produces a new value when npm's reported version actually +changes, a same-value cache refresh returns the identical string and the existing dedup logic in +`version-warnings.ts` naturally stays silent — no code change needed there. + +A live-tracked agent whose installed version is *ahead of* the tracked one has usually self-updated +since the (up to 24h old) cached lookup, so the launch notice, `codemie setup` and `codemie doctor` +don't advise `install --supported` there — that would suggest a downgrade. Agents with a pinned +version (Copilot CLI) keep the notice when ahead, as before. + +### 5. UI copy + +Once the number follows npm rather than a hand-tested pin, every string that says CodeMie "tested", +"verified" or "recommends" a version is inaccurate. All of them use "tracking" framing instead +(decided during implementation, answering the ticket's open question on terminology): + +- `update.ts` — up-to-date message: "no newer version available"; an agent whose lookup failed is + reported as "Could not check for updates" instead of being silently dropped. +- `setup.ts` — a neutral "Installing Claude Code..." spinner. No "ahead of the tracked v..." line: + being ahead of a live-tracked version gets no advice (§4), so setup shows the plain "installed" line. +- `AgentsCheck.ts` — "CodeMie is tracking v...". +- `install.ts` — "(tracked version)" instead of "(supported version)". +- The launch notice ("CodeMie is tracking X vN; you are running vM"), the `install --supported` + option help, and the two related `tips.json` entries. + +## Acceptance Criteria + +- The allowlisted agents' (Claude, Codex, Gemini, Kimi incl. Kimi ACP) tracked version is sourced + from a cached npm registry lookup when the global toggle is on. On lookup failure or with the toggle + off it is reported as unknown: no notice, warning or update offer. The hardcoded constant is never + presented as current. +- The accessor keys off an explicit named allowlist, not a structural signal like + `metadata.npmPackage` presence — `claude-acp` is never targeted for a live lookup. Agents outside it + (Copilot CLI) keep their pinned version, unchanged. +- `minimumSupportedVersion` still blocks launch below the floor regardless of the toggle or lookup + outcome. +- `checkAgentForUpdate()` no longer special-cases Claude; all allowlisted agents go through one + uniform check. +- A single setting (env var > project > global) gates the startup warning, `codemie setup`, + `codemie doctor` and `codemie update` identically. A global `false` holds in projects that have + their own `workspace` block, and the env var works without an active profile. +- An invalid or unrecognized stored value for the toggle resolves to "checks enabled." +- `install --supported` with an unknown tracked version installs the latest release, and asks first + when the agent is already installed. When the registry `latest` is below the agent's minimum, it + installs nothing and stops with an error naming both versions. +- No user-facing string claims CodeMie "tested", "verified" or "recommends" a version; they use the + §5 "tracking" framing. Other copy changes are limited to the checks-disabled notes in + `codemie update` / `codemie install --supported`, and hiding the "Latest tracked version" line of + the below-minimum message when the version is unknown. +- A cache refresh that resolves to an unchanged version does not re-trigger `VersionWarningStore`'s + notice. + +## Non-goals + +- `minimumSupportedVersion` stays hardcoded and keeps blocking (even with checks off). Its + comparison is only moved ahead of the unknown-version exit so it keeps working, and its message + drops the "Latest tracked version" line when that version is unknown. +- New `codemie doctor` features. `doctor` already compares versions on `main` (#553) through the + shared gate, so it follows the tracked version automatically; there is no forced-refresh flag. +- Forced cache refresh flags for `doctor` or `update` (dropped in PR #576 review; not asked for by + the ticket). `codemie update` always fetches fresh instead, with no flag. +- opencode and pi agents are not touched by this change; Copilot CLI keeps its pinned version. +- Claude ACP is out of scope (no version comparison); Kimi ACP was added to the allowlist because it + is the same binary as Kimi. +- No per-agent toggle granularity — one global switch only. +- Automated backend-compatibility testing of new agent versions against CodeMie. +- `exec()` quoting of the base command in shell mode: split into its own PR. +- Tests: written on explicit request during PR #576 review (version resolution, version cache, + registry client, notice/doctor/update/install version paths). + +## Open Risks + +- `AGENTS.md` describes Copilot CLI as "Analytics ingestion only — never installed or launched by + CodeMie," which is stale against the plugin's actual `install()` method. Flagged as documentation + drift; fixing the guide is out of this ticket's scope. +- A cache miss (fresh install, past 24h, or offline) pays one registry request of up to 3s at + launch; after a failure, lookups are skipped for 10 minutes, so an offline user pays it at most + once per 10 minutes per agent. +- The cache file has no cross-process lock and isn't written atomically: concurrent CLI invocations + are last-write-wins, and a torn file reads as empty (worst case: one extra lookup). +- Private npm registries that require authentication aren't supported by the direct lookup; for + those users the tracked version stays unknown (no notice), which fails safely. +- Known, pre-existing and out of scope: `codemie update kimi` updates the npm package, not the + native Kimi binary; a malformed installed version skips the minimum gate. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json new file mode 100644 index 000000000..0e82df22e --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json @@ -0,0 +1,40 @@ +{ + "schema": 1, + "generated": "2026-10-07T00:00:00Z", + "dimensions": { + "component_scope": { "score": 4, "label": "L" }, + "requirements_clarity": { "score": 2, "label": "S" }, + "technical_risk": { "score": 4, "label": "L" }, + "file_change_estimate": { "score": 6, "label": "XXL" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 3, "label": "M" } + }, + "total": 20, + "size": "M", + "band_range": "15-20", + "files_changed": 18, + "routing": "brainstorming", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Targeted fixes across about 6 existing components in 3 layers: the version-cache and npm-registry shared utilities, version-resolution plus BaseAgentAdapter and types in the agent core, and the install and setup CLI commands. No new abstractions; the work extends the existing live-version-tracking design from PR #576. Base M, bumped to L because it touches core shared utilities (version-cache, npm-registry) that every live-tracked agent and command uses." + }, + { + "dimension": "technical_risk", + "reason": "The changes are security-sensitive. The cache key no longer embeds the registry URL (which can carry credentials): it uses the URL origin plus a SHA-256 hash, and legacy raw-URL keys are dropped when the cache loads, so the next save removes them from disk. When a configured npm proxy is invalid, the lookup now fails instead of quietly going direct, and the proxy value is never logged. The closest existing pattern (sanitizeLogArgs-style log scrubbing) does not cover secrets stored in persisted cache keys or the no-direct-fallback rule, so Technical Risk was bumped from M to L. The rest (guarded minimum comparison, AgentInstallationError when the registry latest is below the minimum, setup warning) follows established patterns and is easy to roll back." + }, + { + "dimension": "file_change_estimate", + "reason": "The diffstat reports 18 files changed (472 insertions, 28 deletions), which maps to XXL (16+) on the actual-mode scale. The count is inflated by tests and docs: 9 test files, 2 docs files (CONFIGURATION.md and the PR #576 spec) and 7 source files across src/utils, src/agents/core and src/cli/commands. The source footprint alone would score about L." + }, + { + "dimension": "requirements_clarity", + "reason": "The work comes from specific, itemized code-review findings (round 4 and 5) on an existing PR, each with a clear expected behavior. No open design decisions." + } + ], + "red_flags_applied": [ + "Technical Risk bumped from M to L: security requirement. Registry credentials must never reach the persisted version-cache keys, and a configured npm proxy must not be silently bypassed. The existing log-sanitization pattern does not cover either case.", + "Component Scope bumped from M to L: touches core shared utilities (src/utils/version-cache.ts, src/utils/npm-registry.ts) used by every live-tracked agent, install, setup and update." + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md new file mode 100644 index 000000000..c08ce1420 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md @@ -0,0 +1,22 @@ +# Code review — 2026-10-07-pr576-review-round4-fixes (2026-10-07) + +**request-changes** · confidence: high · 8 blocking · 2 deferred · 16 filtered as noise +Coverage: blind ✓ · edge-case ✓ · verification-gap ✓ · acceptance ✓ (4/4 lenses ran) + +## Look here first + +- `src/cli/commands/install.ts:180` — [other: backwards compatibility] `--supported` on a lagging mirror prompts "Reinstall with the latest release?" but installs the minimum, which can downgrade the agent — CR-003 +- `src/cli/commands/install.ts:130` — [other: lagging mirror] plain `install claude/codex` installs the below-minimum `latest` that the launch gate then refuses — CR-002 +- `src/cli/commands/setup.ts:787` — [other: version gate] setup shows a green "installed" line for a Claude version below the minimum — CR-005 +- `src/utils/npm-registry.ts:117` — [security] an invalid npm proxy setting silently sends the lookup direct, bypassing the configured proxy — CR-007 +- `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` — [other: spec] spec §2 does not describe the install-the-minimum path — CR-001 + +## Also flagged + +- `src/utils/npm-registry.ts` — [infra] the user's .npmrc `cafile`/`ca`/`strict-ssl` settings are ignored, so lookups fail behind TLS-intercepting proxies — CR-008 +- `src/cli/commands/install.ts:213` — [other: copy] `install opencode|pi --supported` blames disabled checks or npm, and no test covers it — CR-004 +- `src/cli/commands/update.ts:63` — [other: tests] no test for a failed built-in agent lookup now reported as LOOKUP_FAILED — CR-006 + +## Checked and clean + +commit-format ✓ · code-quality ✓ · security ✓ · 2 deferred → code-review-deferred.md diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md new file mode 100644 index 000000000..dbae34688 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md @@ -0,0 +1,4 @@ +# Deferred from code review — 2026-10-07-pr576-review-round4-fixes (2026-10-07) + +- **checkAndInstallClaude has no test** — `src/cli/commands/setup.ts:787` — The first-run setup branch for an installed Claude changed its isNewer output and its timeout, and no test reaches checkAndInstallClaude. Pre-existing: the original task explicitly excludes adding a checkAndInstallClaude test from this round. +- **Kimi update routed through npm** — `src/cli/commands/update.ts` — updateAgent special-cases only Claude for the native installer, so a live-tracked Kimi update falls through to npm installGlobal. Pre-existing: the original task names "Kimi update via npm" as a pre-existing item deferred last round. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json new file mode 100644 index 000000000..39045fc92 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json @@ -0,0 +1,162 @@ +{ + "decision": "request-changes", + "rationale": "All four lenses and the standards audit ran, and every candidate was checked against source. Eight blockers remain, mostly in the new lagging-mirror (liveBelowMinimum) path: install copy and target disagree, plain install still pulls the refused release, and setup shows a below-minimum Claude as installed; story AC and spec §2 are partial. 2 deferred (no checkAndInstallClaude test, excluded by the task; Kimi update via npm, pre-existing), see code-review-deferred.md; 16 dismissed as noise, by-design per spec, or already handled.", + "confidence": "high", + "risk_flags": ["breaking-change", "security"], + "business_review": [ + { "kind": "spec", "item": "§1 version-cache.ts getCachedLatestVersion persisting {version,packages,failures} under ~/.codemie outside ConfigLoader", "status": "pass", "notes": "CacheFile shape and getCodemiePath path match" }, + { "kind": "spec", "item": "§1 Cache key = origin without userinfo + '#' + SHA-256 of full URL; legacy raw-URL keys dropped on load", "status": "pass", "notes": "versionCacheKey + KEY_PATTERN filter; tests cover secret/legacy" }, + { "kind": "spec", "item": "§1 TTL 24h from fetchedAt; future fetchedAt is stale", "status": "pass", "notes": "isWithin requires ageMs >= 0" }, + { "kind": "spec", "item": "§1 Failed lookup returns null, never expired entry, logged via logger.warn", "status": "pass", "notes": "version pattern validated; null on non-200/invalid/timeout" }, + { "kind": "spec", "item": "§1 Failure recorded; lookups skipped 10 min; success clears it", "status": "pass", "notes": "FAILURE_BACKOFF_MS; success deletes failure key" }, + { "kind": "spec", "item": "§1 Failed cache write still returns value; malformed file reads empty and is healed", "status": "pass", "notes": "updateCache catches; loadCache returns emptyCache" }, + { "kind": "spec", "item": "§1 bypassCache skips fresh entry/failure, still writes back; used by update", "status": "pass", "notes": "checkAgentForUpdate passes bypassCache: true" }, + { "kind": "spec", "item": "§1 Direct HTTPS GET //latest with 3s limit instead of npm view", "status": "pass", "notes": "FETCH_TIMEOUT_MS bounds proxy discovery + request" }, + { "kind": "spec", "item": "§1 Honors registry, @scope:registry (user .npmrc), proxy/noproxy from .npmrc and npm_config_* env", "status": "pass", "notes": "resolveRegistry/npmSetting/proxyAgentFor" }, + { "kind": "spec", "item": "§1 Under npm/npx, npm_config_* env (incl. userconfig) ignored; only ~/.npmrc read", "status": "pass", "notes": "launchedByNpm gate; test covers it" }, + { "kind": "spec", "item": "§1 Without npm proxy, uses HTTPS_PROXY/HTTP_PROXY/NO_PROXY then Windows system proxy/PAC", "status": "pass", "notes": "falls back to getProxyAgentForUrl" }, + { "kind": "spec", "item": "§1 Project .npmrc deliberately not read", "status": "pass", "notes": "test 'ignores the current project .npmrc'" }, + { "kind": "spec", "item": "§2 Shared accessor resolveSupportedVersionDetailed used by checkVersionCompatibility and checkAgentForUpdate", "status": "pass", "notes": "both call it" }, + { "kind": "spec", "item": "§2.1 Toggle off: nothing current, no network I/O", "status": "pass", "notes": "returns fallback before lookup" }, + { "kind": "spec", "item": "§2.2 Named allowlist claude, codex, gemini, kimi, kimi-acp; not claude-acp", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES" }, + { "kind": "spec", "item": "§2.3 Allowlisted agent uses cache + extractVersion; failure/prerelease returns fallback, not current", "status": "pass", "notes": "PRERELEASE_SUFFIX_PATTERN; catch returns fallback" }, + { "kind": "spec", "item": "§2.4 Other agents with pinned version (Copilot CLI) keep it current", "status": "pass", "notes": "non-allowlisted branch" }, + { "kind": "spec", "item": "§2 checkVersionCompatibility exposes versionKnown; unknown -> 'latest', compatible, no update", "status": "pass", "notes": "BaseAgentAdapter !versionKnown early return" }, + { "kind": "spec", "item": "§2 minimumSupportedVersion gate computed independently, always applies", "status": "pass", "notes": "isBelowMinimum computed before unknown exit" }, + { "kind": "spec", "item": "§2 installVersion('supported') installs current version, or 'latest' when unknown, never the stale constant", "status": "partial", "notes": "liveBelowMinimum installs minimumSupportedVersion, a path the spec does not describe (CR-001)" }, + { "kind": "spec", "item": "§2 run() resolves compatibility once, shared by minimum gate and notice", "status": "pass", "notes": "test 'resolves version compatibility once'" }, + { "kind": "spec", "item": "§2 checkVersionCompatibility async; callers await it", "status": "pass", "notes": "install/setup/AgentsCheck await" }, + { "kind": "spec", "item": "§2 checkAgentForUpdate Claude special-case deleted", "status": "pass", "notes": "uniform allowlisted path" }, + { "kind": "spec", "item": "§3 WorkspaceConfig.versionChecks.enabled (default true), env CODEMIE_VERSION_CHECKS_ENABLED", "status": "pass", "notes": "env/types.ts + config.ts" }, + { "kind": "spec", "item": "§3 Resolved env > project > global without ConfigLoader.load(); works without profile", "status": "pass", "notes": "raw config reads; precedence tests" }, + { "kind": "spec", "item": "§3 Fail-safe: only literal false / 'false' disables", "status": "pass", "notes": "test 'treats an unrecognized value as enabled'" }, + { "kind": "spec", "item": "§3 Disabled: launch notice silent", "status": "pass", "notes": "warnOnceIfUntested returns on versionKnown false" }, + { "kind": "spec", "item": "§3 Disabled: setup plain 'installed' line; missing Claude offered with neutral copy", "status": "pass", "notes": "setup.ts hunks" }, + { "kind": "spec", "item": "§3 Disabled: doctor shows no 'tracking vX' warning", "status": "pass", "notes": "AgentsCheck guards versionKnown" }, + { "kind": "spec", "item": "§3 Disabled: update skips agents with dim 'version checks are disabled' note", "status": "pass", "notes": "update.ts dim notes; tests" }, + { "kind": "spec", "item": "§3 Minimum-version block unchanged when disabled", "status": "pass", "notes": "doctor test covers it" }, + { "kind": "spec", "item": "§4 VersionWarningStore keyed on resolved supportedVersion; version-warnings.ts unchanged", "status": "pass", "notes": "not in diff" }, + { "kind": "spec", "item": "§4 Live-tracked agent ahead of tracked version: no install --supported advice; pinned agents keep notice", "status": "pass", "notes": "isAheadOfLiveTracking" }, + { "kind": "spec", "item": "§5 update.ts 'no newer version available'; failed lookup 'Could not check for updates'", "status": "pass", "notes": "upToDateMessage; LOOKUP_FAILED" }, + { "kind": "spec", "item": "§5 setup.ts neutral 'Installing Claude Code...' spinner, no 'ahead of the tracked' line", "status": "pass", "notes": "setup.ts hunks" }, + { "kind": "spec", "item": "§5 AgentsCheck 'CodeMie is tracking v...'", "status": "pass", "notes": "AgentsCheck.ts" }, + { "kind": "spec", "item": "§5 install.ts '(tracked version)' instead of '(supported version)'", "status": "pass", "notes": "versionMessage" }, + { "kind": "spec", "item": "§5 Launch notice, --supported help and two tips.json entries use 'tracking' framing", "status": "pass", "notes": "copy updated" }, + { "kind": "story-ac", "item": "Allowlisted tracked version from cached npm lookup; on failure/toggle off reported unknown; constant never shown as current", "status": "pass", "notes": "no non-resolver reader displays the constant" }, + { "kind": "story-ac", "item": "Accessor keys off named allowlist; claude-acp never looked up; Copilot CLI keeps pinned version", "status": "pass", "notes": "isLiveTrackedAgent" }, + { "kind": "story-ac", "item": "minimumSupportedVersion blocks launch below floor regardless of toggle or lookup outcome", "status": "pass", "notes": "blockIfBelowMinimum" }, + { "kind": "story-ac", "item": "checkAgentForUpdate no longer special-cases Claude", "status": "pass", "notes": "updateAgent native routing is install mechanism" }, + { "kind": "story-ac", "item": "Single setting (env > project > global) gates warning, setup, doctor, update identically", "status": "pass", "notes": "all flow through isVersionChecksEnabled" }, + { "kind": "story-ac", "item": "Invalid or unrecognized stored toggle value resolves to enabled", "status": "pass", "notes": "enabled !== false" }, + { "kind": "story-ac", "item": "install --supported with unknown tracked version installs the latest release, and asks first when installed", "status": "partial", "notes": "on liveBelowMinimum the prompt and note say 'latest release' but the minimum is installed (CR-003)" }, + { "kind": "story-ac", "item": "No user-facing string claims CodeMie 'tested', 'verified' or 'recommends' a version", "status": "pass", "notes": "only comments retain 'recommend'" }, + { "kind": "story-ac", "item": "Unchanged-version cache refresh does not re-trigger VersionWarningStore notice", "status": "pass", "notes": "dedup keyed on version string" }, + { "kind": "spec", "item": "Non-goal: minimumSupportedVersion stays hardcoded; 'Latest tracked version' line dropped when unknown", "status": "pass", "notes": "constants unchanged; test covers line" }, + { "kind": "spec", "item": "Non-goal: no forced cache refresh flags for doctor or update", "status": "pass", "notes": "no new CLI options" }, + { "kind": "spec", "item": "Non-goal: opencode and pi not touched; Copilot CLI keeps pinned version", "status": "pass", "notes": "no plugin files changed" }, + { "kind": "spec", "item": "Non-goal: Claude ACP out of scope", "status": "pass", "notes": "absent from allowlist" }, + { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity", "status": "pass", "notes": "single workspace toggle" }, + { "kind": "spec", "item": "Non-goal: exec() shell-mode quoting split into its own PR", "status": "pass", "notes": "exec() not modified" } + ], + "standards_review": [ + { "kind": "commit-format", "status": "pass", "notes": "All 51 subjects in 2b084ac..HEAD use an allowed type (feat/fix/docs/style/refactor/test) and an allowed scope (agents/utils/cli/config/kimi/tests), are imperative, and are under the 100-char subject-max-length per git-workflow.md" }, + { "kind": "code-quality", "status": "pass", "notes": "New exported functions in version-resolution.ts, version-cache.ts, npm-registry.ts and version-utils.ts have explicit return types and JSDoc; imports use .js extensions or the @/ alias; no require() or any added. console.log additions in install.ts/update.ts/setup.ts are chalk-formatted CLI user output, not debug output. The generic Error in setup.ts:781 and the ~100-line checkAgentForUpdate in update.ts:41 predate the base (only the timeout constant and internals changed), so they are not introduced by this change. BaseAgentAdapter.ts (1445 lines) was already over the 500-line file guideline before this change" }, + { "kind": "security", "status": "pass", "notes": "npm-registry.ts reads only the user .npmrc (project .npmrc and npm-exported npm_config_* ignored under npm), so a checked-out repo cannot redirect the registry or proxy or exfiltrate env vars; version-cache.ts keys by URL origin plus a SHA-256 hash so registry credentials never reach the cache file, and legacy raw-URL keys are dropped on load; registry responses are size-capped (1 MB) and validated against a strict version regex before being cached or used as an install target; no secrets, registry URLs or proxy URLs are logged; no attribution headers touched" } + ], + "findings": [ + { + "id": "CR-001", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md", + "title": "Spec §2 omits install-the-minimum path", + "problem": "Spec §2 says installVersion('supported') installs the current tracked version, or the 'latest' channel when that version is unknown. The round-4 CR-002 fix added a third outcome: when the registry's latest is below minimumSupportedVersion (liveBelowMinimum), resolveSupportedInstallVersion returns the minimum (src/agents/core/version-resolution.ts:157-163). The spec does not describe this, and the spec file has no hunk in the diff.", + "impact": "A spec requirement is only partially met: the approved design and the shipped behaviour disagree on what --supported installs. Later reviews and fixes will treat the minimum-install path as a deviation.", + "recommendation": "Update spec §2 (and the matching story AC wording) to document the liveBelowMinimum outcome: install minimumSupportedVersion when the registry latest is below the minimum." + }, + { + "id": "CR-002", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 130, + "title": "Plain install pulls below-minimum latest release", + "problem": "For a plain `codemie install claude|codex`, compat.versionKnown is false whenever liveBelowMinimum applies. versionToInstall therefore stays undefined, and agent.install() installs the registry's latest. That is the release below the minimum. Only installVersion('supported') consults liveBelowMinimum, so the round-4 CR-002 fix does not reach the default install path.", + "impact": "Confirmed live path. On a lagging mirror or a mis-set dist-tag, the default install command installs a version that blockIfBelowMinimum then refuses to launch, and the user is left with an agent that will not start.", + "recommendation": "When compat.versionKnown === false on the claude/codex default path, still route through installVersion('supported') (versionToInstall = 'supported'), so liveBelowMinimum installs the minimum and an unknown version installs 'latest'. Alternatively, expose liveBelowMinimum on the compat result and branch on it." + }, + { + "id": "CR-003", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 180, + "title": "--supported prompt says latest, installs minimum", + "problem": "With liveBelowMinimum, checkVersionCompatibility reports versionKnown: false, so install --supported sets trackedVersionUnknown. It then prints 'the tracked version is unavailable (version checks disabled or npm unreachable)', asks 'Reinstall with the latest release?' and notes 'installing the latest release' (lines 180-219). But installVersion('supported') resolves to minimumSupportedVersion (version-resolution.ts:159-160). The story AC 'install --supported with an unknown tracked version installs the latest release, and asks first' is therefore only partly met.", + "impact": "The user confirms one target and gets another. If the installed version is above the minimum, the agent is silently downgraded to the minimum after the user agreed to a 'latest' reinstall. The stated cause (checks disabled or npm unreachable) is also false, because the registry did answer.", + "recommendation": "Surface liveBelowMinimum (or the resolved install target) through VersionCompatibilityResult. In this case, word the notice and prompt as 'registry latest vX is below the minimum; install minimum vY?'. Skip or warn when the installed version is already >= the minimum, rather than offering a downgrade." + }, + { + "id": "CR-004", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 213, + "title": "Unpinned agents --supported misreport cause, untested", + "problem": "opencode and pi have no supportedVersion and do not override installVersion or checkVersionCompatibility. The resolver returns {undefined, isCurrent:false}, so `install opencode|pi --supported` now sets trackedVersionUnknown, prints 'Tracked version unavailable (version checks disabled or npm unreachable)', and installs latest. Previously this failed with an explicit 'No supported version defined' error. No install test covers this, because the --supported tests only use codex mocks.", + "impact": "With checks on and npm reachable, users are told checks are disabled or npm is unreachable. The changed behaviour (silent unpinned latest instead of an error) is not protected by any test.", + "recommendation": "Distinguish 'no tracked version for this agent' from 'lookup unavailable' in the install copy (or keep an explicit error for agents without a pinned or live-tracked version). Add an install.version-selection test for an unpinned BaseAgentAdapter agent with --supported." + }, + { + "id": "CR-005", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 787, + "title": "Setup shows below-minimum Claude as installed", + "problem": "checkAndInstallClaude prints a green '✓ Claude Code vX is installed' whenever compat.compatible || compat.isNewer. A Claude below minimumSupportedVersion yields compatible: true, both on the !versionKnown early return (BaseAgentAdapter.ts:338-348) and when versionKnown (comparison <= 0). isBelowMinimum is never consulted in setup.ts, and this hunk replaced the prior branch.", + "impact": "Confirmed live path. First-run setup tells the user their Claude is fine, and the launch gate then refuses it. The user gets no 'install --supported' hint during setup.", + "recommendation": "Check compat.isBelowMinimum first and print the below-minimum warning with `codemie install claude --supported` before the compatible/isNewer success branch." + }, + { + "id": "CR-006", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 63, + "title": "Built-in lookup failure path untested", + "problem": "A null npm.getLatestVersion(CLI_PACKAGE_NAME) for the built-in codemie-code agent now returns LOOKUP_FAILED instead of null. No update test registers an isBuiltIn agent with a failing lookup: all 'Could not check' assertions in cli-misc-coverage.test.ts use codex or gemini fixtures that take the line-109 branch.", + "impact": "If line 63 regressed to `return null`, an offline `codemie update` would silently drop CodeMie Code, or report 'No updatable agents installed', and no test would fail.", + "recommendation": "Add a cli-misc-coverage case with metadata.isBuiltIn: true and npmMock.getLatestVersion resolving null. Assert 'Could not check for updates' and that 'No updatable agents installed' is not printed." + }, + { + "id": "CR-007", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/npm-registry.ts", + "line": 117, + "title": "Invalid npm proxy silently bypasses proxy", + "problem": "proxyAgentFor constructs new HttpsProxyAgent/HttpProxyAgent(npmProxy) from the user's .npmrc or npm_config_* proxy (line 103). A malformed value throws, and proxyAgentWithin maps that rejection to { agent: undefined } (lines 117-120). The registry request then goes out directly, skipping the env and system proxy fallback as well, with no log line.", + "impact": "The lookup leaves the machine outside the proxy the user configured, which matters on policy-controlled corporate networks and is a security-relevant routing change. Where direct egress is blocked, it fails with no diagnostic.", + "recommendation": "Separate a proxy-construction error from a discovery failure. When an explicitly configured npm proxy cannot be built, log at debug level and return null (fail the lookup) instead of going direct." + }, + { + "id": "CR-008", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/npm-registry.ts", + "title": "npm CA/strict-ssl settings ignored by lookup", + "problem": "fetchLatestVersionFromRegistry and proxyAgentFor honour registry and proxy settings from the user's .npmrc but never read cafile, ca or strict-ssl. No CA option is passed to the HTTPS request. The docs state npm proxy settings are honoured and are silent on CA settings.", + "impact": "Behind a TLS-intercepting corporate proxy where npm works only because of cafile=, every lookup fails with a certificate error. Live-tracked agents then stay permanently 'unknown', and a launch can wait up to FETCH_TIMEOUT_MS each time the 10-minute backoff expires.", + "recommendation": "Read cafile/ca from the user .npmrc (same trust boundary as the proxy settings) and pass them as the request's ca. Also honour strict-ssl=false explicitly, or at minimum document the limitation and the CODEMIE_VERSION_CHECKS_ENABLED=false workaround." + } + ] +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head new file mode 100644 index 000000000..866d1d0a1 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head @@ -0,0 +1 @@ +065071432d8c643f4e3a1437a2c8aed0fd13b1e9 diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl new file mode 100644 index 000000000..068e7579a --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-10-07T12:17:51Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved 4-task plan","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-10-07T15:06:53Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","rationale":"Fix: simplify CR-002/003 (stop with clear error when registry latest < minimum, drop install-the-minimum path); CR-001 spec, CR-004 copy+test, CR-005 setup minimum check, CR-006 test, CR-007 fail lookup on bad npm proxy; CR-008 docs only","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-10-07T16:03:40Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved fix-up for CR-001..CR-008","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl new file mode 100644 index 000000000..258d0ce6a --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl @@ -0,0 +1,5 @@ +{"schema":1,"ts":"2026-10-07T12:17:51Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"skipped"} +{"schema":1,"ts":"2026-10-07T15:06:53Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-10-07T16:03:40Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"actual","status":"skipped"} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json new file mode 100644 index 000000000..d0fe1dad6 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json @@ -0,0 +1,27 @@ +{ + "schema": 1, + "branch": "feat/agents-live-version-check", + "head": "9d1abd7e0e3f063eeb9ceed400cda13e063b1d16", + "runner": "npm", + "started_at": "2026-10-07T16:08:13Z", + "completed_at": "2026-10-07T16:23:51Z", + "status": "PASSED", + "drift_detected": false, + "gates": [ + {"id": "license", "source": "guide", "status": "PASS", "duration_ms": 12959, "command": "npm run license-check", "exit_code": 0}, + {"id": "lint", "source": "guide", "status": "PASS", "duration_ms": 41860, "command": "npm run lint", "exit_code": 0}, + {"id": "typecheck", "source": "guide", "status": "PASS", "duration_ms": 20034, "command": "npm run typecheck", "exit_code": 0}, + {"id": "build", "source": "guide", "status": "PASS", "duration_ms": 33578, "command": "npm run build", "exit_code": 0}, + {"id": "unit", "source": "guide", "status": "PASS", "duration_ms": 114890, "command": "npx vitest run --project unit", "exit_code": 0, "notes": "323 files passed; 4827 tests passed, 2 skipped"}, + {"id": "integration", "source": "guide", "status": "PASS", "duration_ms": 81242, "command": "npx vitest run --project cli", "exit_code": 0, "notes": "37 files passed, 1 skipped; 278 tests passed, 10 skipped"}, + {"id": "secrets", "source": "guide", "status": "SKIPPED", "duration_ms": 4490, "command": "npm run validate:secrets", "exit_code": 1, "notes": "Guide Skip-if met (no running container engine; podman installed but machine not started): 'No container engine found — install Docker, Podman, or Apple Containers to enable local secrets scanning.' Enable with 'podman machine start'. CI gitleaks still owed."}, + {"id": "commitlint-last", "source": "guide", "status": "PASS", "duration_ms": 2933, "command": "npm run commitlint:last", "exit_code": 0}, + {"id": "pre-commit-aggregate", "source": "guide", "status": "PASS", "duration_ms": 63765, "command": "npm run check:pre-commit", "exit_code": 0}, + {"id": "full-ci", "source": "guide", "status": "PASS", "duration_ms": 238004, "command": "npm run ci", "exit_code": 0, "notes": "license+lint+build+unit (4827 passed) +cli (278 passed) all green"}, + {"id": "lint-staged", "source": "hook", "status": "SKIPPED", "duration_ms": 3583, "command": "npx lint-staged", "exit_code": 0, "notes": "Self-skip: 'lint-staged could not find any staged files.' Its eslint + adjacent-test work is covered by lint/unit gates over the full tree."}, + {"id": "commitlint-range", "source": "ci", "status": "PASS", "duration_ms": 3719, "command": "npx commitlint --from 2b084ac697e7f8558228295573aeec7dbdcd0e10 --to HEAD --verbose", "exit_code": 0, "notes": "55/55 commits, 0 problems. Mirrors CI validate-commits and the commit-msg hook; guide's commitlint:last checks only HEAD~1..HEAD."}, + {"id": "pr-title", "source": "ci", "status": "N/A", "command": "gh pr view --json title -q .title | npx commitlint --verbose", "notes": "Requires the live GitHub PR title; settle in CI or run the pipe manually against PR #576."}, + {"id": "gitleaks-ci", "source": "ci", "status": "N/A", "command": "gitleaks/gitleaks-action@v2 (GitHub Actions)", "notes": "CI scans the PR commit range, local validate:secrets scans only the staged diff (mismatch). Owed to CI, or run gitleaks via a started podman machine against the branch range."} + ], + "failures": {} +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl new file mode 100644 index 000000000..116556016 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl @@ -0,0 +1,4 @@ +{"task_id":"1","status":"done","commit":"23f49f9","test_command":"npx vitest run --project unit src/utils/__tests__/version-cache.test.ts"} +{"task_id":"2","status":"done","commit":"501712d","test_command":"npx vitest run --project unit src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts"} +{"task_id":"3","status":"done","commit":"1bef8f3","test_command":"npx vitest run --project unit src/agents/core/__tests__/version-resolution.test.ts"} +{"task_id":"4","status":"done","commit":"0650714","test_command":"npx vitest run --project unit src/utils/__tests__/npm-registry.test.ts"} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md new file mode 100644 index 000000000..703eec3a3 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md @@ -0,0 +1,61 @@ +```json +[ + {"kind": "spec", "item": "§1 New src/utils/version-cache.ts exposing getCachedLatestVersion(packageName) persisting {version:1, packages, failures} under ~/.codemie/ outside ConfigLoader schema", "status": "pass", "notes": "version-cache.ts new file; filePath() = getCodemiePath('version-cache.json'); CacheFile shape matches"}, + {"kind": "spec", "item": "§1 Cache key = registry origin without userinfo + '#' + SHA-256 of full URL; legacy raw-URL keys dropped on load", "status": "pass", "notes": "versionCacheKey() uses URL.origin + sha256; loadCache filters by KEY_PATTERN; covered by version-cache.test.ts secret/legacy tests"}, + {"kind": "spec", "item": "§1 TTL 24h from fetchedAt; future fetchedAt counts as stale", "status": "pass", "notes": "TTL_MS 24h; isWithin() requires ageMs >= 0; test 'treats a fetchedAt in the future as stale'"}, + {"kind": "spec", "item": "§1 Failed lookup (timeout/network/non-200/non-version) returns null, never the expired entry, logged via logger.warn", "status": "pass", "notes": "getCachedLatestVersion validates NPM_VERSION_PATTERN, logger.warn then returns null; npm-registry.ts returns null on non-200/invalid JSON/timeout"}, + {"kind": "spec", "item": "§1 Failure recorded; lookups skipped for 10 minutes; later success clears it", "status": "pass", "notes": "FAILURE_BACKOFF_MS 10min; success path deletes cache.failures[key]; test 'skips lookups for 10 minutes after a failure'"}, + {"kind": "spec", "item": "§1 Failed cache write still returns fetched value; malformed/torn file reads as empty and is healed by next write", "status": "pass", "notes": "updateCache catches and warns; loadCache returns emptyCache on parse error/bad shape; tests cover both"}, + {"kind": "spec", "item": "§1 bypassCache option skips fresh entry/recent failure, still writes back; used by codemie update", "status": "pass", "notes": "getCachedLatestVersion options.bypassCache; update.ts checkAgentForUpdate passes bypassCache: true"}, + {"kind": "spec", "item": "§1 Direct HTTPS GET of //latest with 3s limit instead of spawning npm view", "status": "pass", "notes": "npm-registry.ts fetchLatestVersionFromRegistry builds `${name with %2f}/latest`; FETCH_TIMEOUT_MS 3000 bounds proxy discovery + request"}, + {"kind": "spec", "item": "§1 Honors registry, @scope:registry (user .npmrc only), https-proxy/proxy/noproxy from user .npmrc and npm_config_* env vars for unscoped settings", "status": "pass", "notes": "resolveRegistry reads config[`${scope}:registry`] from user npmrc only; npmSetting() env > user npmrc; proxyAgentFor applies noproxy then npm proxy"}, + {"kind": "spec", "item": "§1 When launched by npm/npx, npm_config_* env vars (incl. npm_config_userconfig) are ignored and only ~/.npmrc is read", "status": "pass", "notes": "launchedByNpm() gates npmSetting env path and loadNpmConfig userconfig; test 'ignores npm_config_* env vars when launched via npm/npx'"}, + {"kind": "spec", "item": "§1 Without an npm proxy, uses HTTPS_PROXY/HTTP_PROXY/NO_PROXY then Windows system proxy/PAC", "status": "pass", "notes": "proxyAgentFor falls back to getProxyAgentForUrl(); env NO_PROXY merged via getEnvNoProxyEntries"}, + {"kind": "spec", "item": "§1 Project .npmrc is deliberately not read", "status": "pass", "notes": "loadNpmConfig reads only userconfig or homedir()/.npmrc; test 'ignores the current project .npmrc'"}, + {"kind": "spec", "item": "§2 Single shared accessor resolveSupportedVersionDetailed() in src/agents/core/version-resolution.ts returning {version, isCurrent}, used by checkVersionCompatibility() and checkAgentForUpdate()", "status": "pass", "notes": "version-resolution.ts new; BaseAgentAdapter.checkVersionCompatibility and update.ts checkAgentForUpdate both call it"}, + {"kind": "spec", "item": "§2.1 With global toggle off, nothing is current for any agent and there is no network I/O", "status": "pass", "notes": "resolveSupportedVersionDetailed returns fallback (isCurrent false) before isLiveTrackedAgent/getCachedLatestVersion; test 'is not live when version checks are disabled, and skips the lookup'"}, + {"kind": "spec", "item": "§2.2 Explicit named allowlist claude, codex, gemini, kimi, kimi-acp; claude-acp not included", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES constant; isLiveTrackedAgent keyed by name, not npmPackage"}, + {"kind": "spec", "item": "§2.3 Allowlisted agent consults cache, extracts with extractVersion(); only a successful lookup is current; failure or prerelease returns fallback with isCurrent false", "status": "pass", "notes": "PRERELEASE_SUFFIX_PATTERN rejection, extractVersion moved to version-utils.ts, catch returns fallback; tests cover failure/null/prerelease"}, + {"kind": "spec", "item": "§2.4 Any other agent with a pinned version (Copilot CLI) keeps it as current", "status": "pass", "notes": "non-allowlisted branch returns {version: fallback, isCurrent: Boolean(fallback)} when toggle on; test 'keeps the maintainer-pinned version current'"}, + {"kind": "spec", "item": "§2 checkVersionCompatibility exposes isCurrent as versionKnown; when false reports supportedVersion 'latest', compatible true, no update", "status": "pass", "notes": "BaseAgentAdapter.ts !versionKnown early return; types.ts adds versionKnown; codex test 'reports the tracked version as unknown'"}, + {"kind": "spec", "item": "§2 minimumSupportedVersion gate computed independently and still applies in every case", "status": "pass", "notes": "isBelowMinimum computed before !versionKnown exit; test 'still refuses to launch when the tracked version is unknown'"}, + {"kind": "spec", "item": "§2 installVersion('supported') installs the current version, or the 'latest' channel when unknown — never the stale constant", "status": "partial", "notes": "resolveSupportedInstallVersion returns minimumSupportedVersion when liveBelowMinimum (version-resolution.ts:937-939), a path the spec does not describe; base/claude/kimi otherwise correct"}, + {"kind": "spec", "item": "§2 run() resolves compatibility once and shares it between the minimum gate and the notice", "status": "pass", "notes": "run() computes compat once and passes to blockIfBelowMinimum(compat)/warnOnceIfUntested(compat); test 'resolves version compatibility once'"}, + {"kind": "spec", "item": "§2 checkVersionCompatibility async; callers (run, install.ts, update.ts, AgentsCheck.ts, setup.ts) await it", "status": "pass", "notes": "callers await in diff hunks of install.ts, setup.ts (Promise.race), AgentsCheck.ts; update.ts uses resolver directly"}, + {"kind": "spec", "item": "§2 checkAgentForUpdate() Claude special-case deleted; Claude goes through the uniform allowlisted path", "status": "pass", "notes": "update.ts hunk @@ -56,29 +53,6 removes the claude branch; live-tracked agents use resolveSupportedVersionDetailed"}, + {"kind": "spec", "item": "§3 New WorkspaceConfig.versionChecks.enabled (default true), stored in global/project codemie-cli.config.json, env var CODEMIE_VERSION_CHECKS_ENABLED", "status": "pass", "notes": "env/types.ts adds versionChecks?: {enabled?}; config.ts adds 'versionChecks' to workspace key list; isVersionChecksEnabled reads env"}, + {"kind": "spec", "item": "§3 Resolved field by field env > project > global, not via ConfigLoader.load(); works without active profile", "status": "pass", "notes": "isVersionChecksEnabled uses loadLocalMultiProviderConfig/loadMultiProviderConfig (raw file reads, no profile check); tests for global false under project block and env with no profile"}, + {"kind": "spec", "item": "§3 Fail-safe: only literal false (config) / 'false' (env) disables; any other value enables", "status": "pass", "notes": "envValue !== 'false'; enabled !== false; empty env treated as unset; test 'treats an unrecognized value as enabled'"}, + {"kind": "spec", "item": "§3 Disabled: launch notice silent", "status": "pass", "notes": "warnOnceIfUntested returns when compat.versionKnown === false; test 'stays silent when the tracked version is unknown'"}, + {"kind": "spec", "item": "§3 Disabled: codemie setup shows plain 'installed' line; missing Claude still offered for install with neutral copy", "status": "pass", "notes": "setup.ts: compatible||isNewer -> green installed line; spinner 'Installing Claude Code...'"}, + {"kind": "spec", "item": "§3 Disabled: codemie doctor shows no 'tracking vX' warning", "status": "pass", "notes": "AgentsCheck.ts guards on compat.versionKnown !== false; test 'stays ok, never \"tracking vlatest\"'"}, + {"kind": "spec", "item": "§3 Disabled: codemie update skips these agents with a dim 'version checks are disabled' note instead of 'Could not check'", "status": "pass", "notes": "update.ts single-agent dim note + early return; all-agents dim note and checkAgentForUpdate returns null; cli-misc-coverage tests"}, + {"kind": "spec", "item": "§3 Minimum-version block unchanged when disabled", "status": "pass", "notes": "isBelowMinimum independent of versionKnown; doctor test 'still reports a below-minimum version when the tracked version is unknown'"}, + {"kind": "spec", "item": "§4 VersionWarningStore keeps keying on resolved supportedVersion; no change to version-warnings.ts", "status": "pass", "notes": "version-warnings.ts not in diff; recordWarning still receives supportedVersion"}, + {"kind": "spec", "item": "§4 Live-tracked agent installed ahead of tracked version: launch notice, setup and doctor don't advise install --supported; pinned agents (Copilot CLI) keep the notice when ahead", "status": "pass", "notes": "isAheadOfLiveTracking used in warnOnceIfUntested and AgentsCheck; setup drops isNewer advice; tests for claude vs copilot-cli"}, + {"kind": "spec", "item": "§5 update.ts: up-to-date message 'no newer version available'; failed lookup reported as 'Could not check for updates'", "status": "pass", "notes": "upToDateMessage for live-tracked agents; LOOKUP_FAILED -> unchecked list / spinner.warn"}, + {"kind": "spec", "item": "§5 setup.ts neutral 'Installing Claude Code...' spinner and no 'ahead of the tracked' line", "status": "pass", "notes": "setup.ts hunks @@ -729 and @@ -772"}, + {"kind": "spec", "item": "§5 AgentsCheck.ts 'CodeMie is tracking v...'", "status": "pass", "notes": "AgentsCheck.ts:68"}, + {"kind": "spec", "item": "§5 install.ts '(tracked version)' instead of '(supported version)'", "status": "pass", "notes": "install.ts versionMessage and '(tracked)' reinstall display"}, + {"kind": "spec", "item": "§5 Launch notice, install --supported help, and two tips.json entries use 'tracking' framing", "status": "pass", "notes": "BaseAgentAdapter notice 'CodeMie is tracking'; install option help; tips.json cmd-install-version and cmd-update"}, + {"kind": "spec", "item": "AC: allowlisted agents' tracked version sourced from cached npm lookup when toggle on; on failure or toggle off reported unknown with no notice/warning/update offer; constant never presented as current", "status": "pass", "notes": "no remaining non-resolver reader of metadata.supportedVersion displays it (grep of src); update returns LOOKUP_FAILED/null rather than fallback"}, + {"kind": "spec", "item": "AC: accessor keys off explicit named allowlist; claude-acp never looked up; Copilot CLI keeps pinned version", "status": "pass", "notes": "isLiveTrackedAgent name list; Copilot non-current only when the global toggle is off, per Design §2.1"}, + {"kind": "spec", "item": "AC: minimumSupportedVersion blocks launch below floor regardless of toggle or lookup outcome", "status": "pass", "notes": "blockIfBelowMinimum uses isBelowMinimum computed before unknown exit; resolver swallows lookup errors"}, + {"kind": "spec", "item": "AC: checkAgentForUpdate no longer special-cases Claude", "status": "pass", "notes": "see §2 row; updateAgent still routes Claude to its native installer, which is installation mechanism not the check"}, + {"kind": "spec", "item": "AC: single setting (env > project > global) gates startup warning, setup, doctor, update identically; global false holds with project workspace block; env works without profile", "status": "pass", "notes": "all four consumers flow through isVersionChecksEnabled via resolver/versionKnown; version-resolution.test.ts covers precedence cases"}, + {"kind": "spec", "item": "AC: invalid or unrecognized stored toggle value resolves to enabled", "status": "pass", "notes": "isVersionChecksEnabled `enabled !== false`; unreadable config -> true"}, + {"kind": "spec", "item": "AC: install --supported with unknown tracked version installs the latest release, and asks first when already installed", "status": "partial", "notes": "prompt and 'latest' install present in install.ts:2134-2174, but when registry latest < minimum it installs the minimum while the prompt/note say 'latest release'"}, + {"kind": "spec", "item": "AC: no user-facing string claims CodeMie 'tested', 'verified' or 'recommends' a version; other copy changes limited to checks-disabled notes and hiding 'Latest tracked version' line", "status": "pass", "notes": "grep of src non-test finds remaining 'recommend' only in comments (version-warnings.ts, install.ts:269); added copy in update/install is checks-disabled/unavailable notes"}, + {"kind": "spec", "item": "AC: unchanged-version cache refresh does not re-trigger VersionWarningStore notice", "status": "pass", "notes": "dedup keyed on supportedVersion string; resolver returns identical string for unchanged npm value"}, + {"kind": "spec", "item": "Non-goal: minimumSupportedVersion stays hardcoded; comparison only moved ahead of unknown-version exit; message drops 'Latest tracked version' line when unknown", "status": "pass", "notes": "constants unchanged; blockIfBelowMinimum guards line on versionKnown !== false; test 'omits the \"Latest tracked version\" line'"}, + {"kind": "spec", "item": "Non-goal: no forced cache refresh flags for doctor or update", "status": "pass", "notes": "no new CLI options besides help text changes; update always uses bypassCache"}, + {"kind": "spec", "item": "Non-goal: opencode and pi not touched; Copilot CLI keeps pinned version", "status": "pass", "notes": "no opencode/pi plugin files in diff; update.ts keeps npm.getLatestVersion path for non-allowlisted agents"}, + {"kind": "spec", "item": "Non-goal: Claude ACP out of scope", "status": "pass", "notes": "claude-acp absent from LIVE_TRACKED_AGENT_NAMES; no claude-acp file changed"}, + {"kind": "spec", "item": "Non-goal: no per-agent toggle granularity", "status": "pass", "notes": "single workspace.versionChecks.enabled"}, + {"kind": "spec", "item": "Non-goal: exec() quoting of the base command in shell mode split into its own PR", "status": "pass", "notes": "exec() itself not modified; diff only passes shell: win32 to getVersion calls with fixed cliCommand names (claude/codex/gemini/kimi)"} +] +``` + +- Spec §2 says `installVersion('supported')` installs the current tracked version, or the `latest` channel when it is unknown. The diff adds a third outcome the spec does not describe: when the registry `latest` is below `minimumSupportedVersion` (`liveBelowMinimum`), `resolveSupportedInstallVersion` returns the minimum version instead (src/agents/core/version-resolution.ts:937-939, test "installs the minimum, not the latest channel"). Either update the spec to document this path or bring the code back in line with the spec. +- Story AC "`install --supported` with an unknown tracked version installs the latest release, and asks first when already installed" is only partly met. When `liveBelowMinimum` applies, `checkVersionCompatibility` reports `versionKnown: false`, so install.ts prompts "Reinstall with the latest release?" and prints "Tracked version unavailable … installing the latest release." But `installVersion('supported')` then installs `minimumSupportedVersion`. The user confirms one target and gets another, and the copy is wrong for this path (src/cli/commands/install.ts:2134-2174 in the diff, together with version-resolution.ts:937-939). +- Note: `changed_files` lists `docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md` and `spec.md`, but the frozen diff has no hunks for either. The audit used the spec as it currently is on disk. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md new file mode 100644 index 000000000..43c6c7dd2 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md @@ -0,0 +1,21 @@ +- The lagging-mirror path contradicts the user-facing text. When the registry's `latest` is below the minimum, `resolveSupportedVersionDetailed` returns `isCurrent: false`, so `install --supported` sets `trackedVersionUnknown`. It then prints "Tracked version unavailable ... installing the latest release" and asks "Reinstall with the latest release?", but `resolveSupportedInstallVersion` actually installs `minimumSupportedVersion`. If the user already runs a version above the minimum, that is a silent downgrade to the minimum after they agreed to a "latest" reinstall — src/cli/commands/install.ts hunk @@ -168,24 +177,58 together with src/agents/core/version-resolution.ts `resolveSupportedInstallVersion` +- The same lagging-mirror case breaks a plain `codemie install codex` / `codemie install claude`. With `versionKnown === false`, `versionToInstall` stays undefined, so `agent.install()` installs whatever the registry calls latest. That is the below-minimum release, which `blockIfBelowMinimum` then refuses to launch. The `liveBelowMinimum` signal is only used by `installVersion('supported')`, not by the default install path — src/cli/commands/install.ts hunk @@ -112,16 +113,24 +- `tests/setup/agent-build-setup.ts` compares the installed Claude version against `resolveSupportedInstallVersion`'s result. When that result is the minimum (lagging mirror), any newer installed Claude counts as a "mismatch" and gets reinstalled at the minimum, which is a downgrade. Only the `'latest'` case is exempt from reinstalling — tests/setup/agent-build-setup.ts hunk @@ -60,44 +60,65 +- `update` reports a lagging-mirror result (`liveBelowMinimum`, `isCurrent: false`) as `LOOKUP_FAILED`, so the user sees "Could not check X for updates" even though the registry answered. Nothing tells them the registry's latest is below the supported minimum — src/cli/commands/update.ts `checkAgentForUpdate` hunk @@ -112,10 +86,28 +- `isVersionChecksEnabled` only treats the exact lowercase string `'false'` as off. `CODEMIE_VERSION_CHECKS_ENABLED=0`, `FALSE`, `False`, `no` or `off` all leave checks enabled with no warning. In the JSON config, `"enabled": "false"` (a string) also counts as enabled. A user trying to stop the 3s offline wait can easily get this wrong and not notice — src/agents/core/version-resolution.ts:828-851 +- The project-scope `.codemie/codemie-cli.config.json` takes precedence over the global `versionChecks.enabled: false`. npm-registry.ts goes out of its way to stop a checked-out repo from influencing the lookup, yet a repo can still re-enable network lookups that a user turned off globally (for example, offline or air-gapped by policy). Check whether that asymmetry is intended — src/agents/core/version-resolution.ts:836-849, docs/CONFIGURATION.md "Precedence" +- The registry lookup ignores the TLS-related npm settings in the user's `.npmrc` (`cafile`, `ca`, `strict-ssl`). Behind a corporate TLS-intercepting proxy where npm itself works only because of `cafile=`, every lookup fails with a cert error. The result is a permanent "lookup failed" state, with a possible 3s launch wait every 10 minutes. The docs say npm proxy settings are honored but say nothing about CA settings — src/utils/npm-registry.ts `requestLatestVersion` / `proxyAgentFor` +- Redirects are not followed: any non-200 status, including 301/302/307 from an Artifactory or Nexus virtual repo or a registry-URL migration, resolves to `null`. That is recorded as a failure and triggers the 10-minute backoff — src/utils/npm-registry.ts:3208-3212 +- Authenticated registries (`_authToken` / `_auth` in `~/.npmrc`, common in corporate setups) are never sent credentials. For those users the lookup fails, and keeps failing, forever. Each time the backoff expires, a launch can block for up to `FETCH_TIMEOUT_MS`, and a `logger.warn` fires. The only signal is a sentence in the docs; nothing at runtime suggests setting `CODEMIE_VERSION_CHECKS_ENABLED=false` — src/utils/npm-registry.ts `fetchLatestVersionFromRegistry`, src/utils/version-cache.ts:3442-3450 +- An invalid npm `proxy` / `https-proxy` value makes the `HttpsProxyAgent` / `HttpProxyAgent` constructor throw inside `proxyAgentFor`. `proxyAgentWithin` turns that rejection into `{ agent: undefined }`, so the request goes out directly, bypassing the proxy the user configured, with no log line — src/utils/npm-registry.ts:3136-3157 +- `MAX_RESPONSE_BYTES` is compared against `body.length`, which counts UTF-16 characters after decoding, not bytes. The cap is approximate. Also, after `request.destroy()` the `'data'` handler can still fire with already-buffered chunks — src/utils/npm-registry.ts:3215-3218 +- The scoped registry `@scope:registry` is read only from the `.npmrc` file (`config[...]`), never from env vars, while plain `registry` honors `npm_config_registry`. A user who sets the scoped registry through the environment gets the default or unscoped registry for `@openai/codex`, `@anthropic-ai/claude-code` and `@google/gemini-cli`. That contradicts the docs' claim of honoring "`npm_config_*` environment variables" — src/utils/npm-registry.ts:3115-3120 +- With checks disabled, `codemie update ` suggests `codemie install ${agent.name} latest`. For Kimi, an explicit `'latest'` goes straight to `installNativeAgent`, because only the `'supported'` path maps `'latest'` to `undefined`. The Kimi test shows the native installer expects `undefined` for latest, so the suggested command may fail or install a version literally named "latest" — src/cli/commands/update.ts hunk @@ -275,22 +273,32 vs src/agents/plugins/kimi/kimi.plugin.ts hunk @@ -334,16 +340,17 +- `updateAgent` special-cases only `claude` for the native installer. Kimi also installs through `installNativeAgent` (see kimi.plugin.ts), and is now live-tracked and offered updates by `codemie update`. It most likely falls through to the npm `installGlobal` path, giving an npm install that sits alongside or conflicts with the native one — src/cli/commands/update.ts `updateAgent` hunk @@ -209,9 +205,10 +- The new interactive `inquirer.prompt` ("Reinstall with the latest release?") is added with no non-interactive or TTY guard. In CI or a piped invocation of `codemie install --supported`, it may hang or throw instead of cancelling cleanly — src/cli/commands/install.ts hunk @@ -168,24 +177,58 +- `versionKnown` is optional, and every consumer (`warnOnceIfUntested`, `AgentsCheck`, `install.ts`, `blockIfBelowMinimum`) treats `undefined` as known. Any adapter that overrides `checkVersionCompatibility` without setting the field falls back to the old behaviour of showing `'latest'` or a fallback as the tracked version. That includes "vlatest" text in doctor and in the install display — src/agents/core/types.ts:740-742, src/cli/commands/doctor/checks/AgentsCheck.ts hunk @@ -57,10 +- In `run()`, when the shared `checkVersionCompatibility()` rejects, `blockIfBelowMinimum` calls it again. A launch can therefore wait for two full lookups, which contradicts the docs' "a launch can wait up to 3 seconds". The comment also says the handler cannot log because `logger` is redeclared later in `run()` (a temporal-dead-zone hazard), so the first failure goes unrecorded — src/agents/core/BaseAgentAdapter.ts hunk @@ -524,9 +565,16 +- `CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000` only budgets for the config read and the fetch. On Windows, `checkVersionCompatibility` also runs `claude --version`, now through a shell, which can take seconds. The outer race can still lose to a slow cold start and print a timeout error during setup — src/cli/commands/setup.ts:2207-2210 +- Stale doc comment: `AgentAdapter.warnOnceIfUntested` still says "differs from the recommended one", while the rest of the change renames this concept to "tracked" — src/agents/core/types.ts hunk @@ -853,9 +859,10 +- `setup.ts` now treats `compat.isNewer` as fine for Claude because "Claude is live-tracked". When version checks are off, `supportedVersion` is `'latest'` and the reasoning no longer applies. Either way, the removed warning branch has no replacement for the case where the installed version is ahead of a stale cache — src/cli/commands/setup.ts hunk @@ -772,21 +778,13 +- Entries in the cache file's `failures` map are only deleted on a success for the same key. Keys for registries the user has stopped using, or for changed registry URLs, accumulate forever — src/utils/version-cache.ts `getCachedLatestVersion` / `updateCache` diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json new file mode 100644 index 000000000..9b6ddc8a7 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json @@ -0,0 +1 @@ +[{"location":"src/cli/commands/install.ts:2103-2108 (diff hunk @@ -112,16 +113,24 @@)","trigger_condition":"Plain install of claude/codex while registry latest is below minimum (liveBelowMinimum)","guard_snippet":"if (compat.versionKnown === false) { versionToInstall = 'supported'; } // installVersion('supported') resolves minimum or 'latest'","potential_consequence":"agent.install() pulls lagging latest; minimum gate then refuses every launch"},{"location":"src/cli/commands/install.ts:2134-2174 (trackedVersionUnknown branch and notice)","trigger_condition":"--supported when tracked version unknown because live latest is below minimum","guard_snippet":"Surface liveBelowMinimum in compat and word message as 'installing minimum vX' instead of 'latest release'","potential_consequence":"Prompt and notice claim latest release while installVersion actually installs the minimum"},{"location":"src/agents/core/version-resolution.ts:831-833","trigger_condition":"CODEMIE_VERSION_CHECKS_ENABLED set to 'False', 'FALSE', '0' or 'no'","guard_snippet":"return !['false','0','no','off'].includes(envValue.toLowerCase());","potential_consequence":"User's disable is ignored; live lookups and 3s offline waits continue on every launch"},{"location":"src/cli/commands/setup.ts:787 with src/agents/core/BaseAgentAdapter.ts:335-347","trigger_condition":"Installed Claude below minimum; !versionKnown branch returns compatible:true alongside isBelowMinimum:true","guard_snippet":"if (compat.isBelowMinimum) { warn below-minimum + 'codemie install claude --supported' } else if (compat.compatible || compat.isNewer) {...}","potential_consequence":"Setup prints a green installed checkmark for a version the launch gate refuses"}] diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json new file mode 100644 index 000000000..93e1bc227 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json @@ -0,0 +1 @@ +[{"location":"src/cli/commands/setup.ts:787 (checkAndInstallClaude: `if (compat.compatible || compat.isNewer)` replaces the isNewer downgrade warning; outer timeout raised to FETCH_TIMEOUT_MS + 2000 at setup.ts:710)","trigger_condition":"The first-time-setup branch for an already-installed Claude changed what it prints when Claude is ahead of the tracked version (green 'installed' instead of the 'only tested vX / install --supported' warning) and how long it waits, but no test runs checkAndInstallClaude.","guard_snippet":"A setup test that mocks AgentRegistry.getAgent('claude') with isInstalled=true and checkVersionCompatibility resolving {isNewer:true, compatible:false, versionKnown:true}, then asserts console output contains 'Claude Code v is installed' and does not contain 'install claude --supported'. A second case should assert that a check slower than 3s but faster than CLAUDE_VERSION_CHECK_TIMEOUT_MS is still used rather than hitting the timeout fallback.","potential_consequence":"If the downgrade advice came back, or if the branch went quiet for isNewer (as the old `else if (compat.compatible)` did), first-run setup would show the wrong message to users whose Claude self-updated. No test would fail, because checkAndInstallClaude is module-private and no test reaches it.","gap_shape":"regression-gap","consumer":"`codemie setup` first-time flow calling checkAndInstallClaude() at src/cli/commands/setup.ts:383","evidence":"Searched src/**/__tests__ and tests/**/*.test.ts for 'checkAndInstallClaude', 'Claude Code is installed', 'isNewer' under src/cli tests, and imports of setup.js. The only importers are src/cli/commands/__tests__/model-tier-auto-selection.test.ts:13, which imports autoSelectModelTiers only, and tests/integration/model-tier-e2e.test.ts:217. Neither reaches the Claude install/version branch."},{"location":"src/agents/core/BaseAgentAdapter.ts:195-201 (installVersion('supported') no longer throws 'No supported version defined in metadata') together with src/cli/commands/install.ts:115-121 and 168-174 (versionKnown===false now means trackedVersionUnknown)","trigger_condition":"For agents with no supportedVersion and no installVersion/checkVersionCompatibility override (opencode, pi), `codemie install --supported` used to fail with an explicit error. It now installs `latest` and prints 'Tracked version unavailable (version checks disabled or npm unreachable)', and no test pins this path.","guard_snippet":"An install.version-selection test that uses a real BaseAgentAdapter-backed agent (or a mock whose checkVersionCompatibility returns versionKnown:false with metadata.supportedVersion undefined) for `opencode --supported`. It should assert the intended outcome: either an explicit 'no tracked version' error, or the install with a message that does not blame disabled checks or an unreachable npm.","potential_consequence":"With checks on and npm reachable, a user running `install opencode --supported` is told that checks are disabled or npm is unreachable, and gets an unpinned latest install where they previously got an error. The new install tests would not notice because they only use a codex mock with hand-built compat objects. version-resolution.test.ts:695 checks only that the resolver returns {undefined, isCurrent:false} for opencode, not what install.ts does with that result.","gap_shape":"regression-gap","consumer":"`codemie install opencode --supported` / `codemie install pi --supported` through src/cli/commands/install.ts:112-121 and BaseAgentAdapter.installVersion (src/agents/core/BaseAgentAdapter.ts:188)","evidence":"Grep of src/agents/plugins/{opencode,pi}/*.ts for supportedVersion|installVersion|checkVersionCompatibility returned no matches, so both use the base adapter. The --supported tests in install.version-selection.test.ts (diff lines 1834-1872) use only codexWithUnknownTrackedVersion mocks. The old throw was removed in the diff (BaseAgentAdapter.ts and claude.plugin.ts / kimi.plugin.ts hunks), and no test asserts the replacement behavior for an agent without a pinned version."},{"location":"src/cli/commands/update.ts:63 (built-in codemie-code: a null npm.getLatestVersion(CLI_PACKAGE_NAME) now returns LOOKUP_FAILED instead of null)","trigger_condition":"The built-in agent's failed lookup is now reported as 'Could not check CodeMie Code for updates' instead of being dropped, but no update test covers a built-in agent whose lookup fails.","guard_snippet":"A cli-misc-coverage case that registers an agent with metadata.isBuiltIn:true and makes npmMock.getLatestVersion resolve null. It should assert that captured() contains 'Could not check for updates' and that spinner.info was not called with 'No updatable agents installed'.","potential_consequence":"If line 63 went back to `return null`, an offline `codemie update` would silently leave out CodeMie Code, or print 'No updatable agents installed', and no test would fail. The new LOOKUP_FAILED tests use only a live-tracked codex mock, which goes through the separate line-109 branch.","gap_shape":"regression-gap","consumer":"checkAllAgentsForUpdates / single-agent update for the built-in codemie-code agent, src/cli/commands/update.ts:57-63 and the unchecked reporting at update.ts:479-489","evidence":"Grep of src/cli/commands/__tests__ for 'isBuiltIn: true' and 'Could not check' found 'Could not check' only at cli-misc-coverage.test.ts:307, 352, 376 and 388. All of them use the codex liveTrackedAgent or gemini fixtures with isBuiltIn:false. No built-in fixture appeared in the update tests I read."}] \ No newline at end of file diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md new file mode 100644 index 000000000..349017600 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md @@ -0,0 +1,114 @@ +# PR #576 Round-4 Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close review findings CR-001..CR-005 on `feat/agents-live-version-check` (EPMCDME-14767). + +**Architecture:** Four local fixes: non-secret cache key in `version-cache.ts` (+ spec text), a guarded fallback lookup in `BaseAgentAdapter.blockIfBelowMinimum`, a below-minimum discriminator in `version-resolution.ts`, and a CONNECT-proxy test for `npm-registry.ts`. + +**Tech Stack:** TypeScript (ESM, `.js` imports), Vitest (`unit`, `cli` projects), `node:crypto`, `node:http`. + +**Spec:** inline requirements; research in `technical-analysis.md` (same dir). + +Commit per task using the repository's existing convention. + +## Acceptance criteria + +- [ ] `~/.codemie/version-cache.json` never contains the resolved registry URL, its userinfo or any path segment; keys are `#|`, shared by `packages` and `failures`. +- [ ] Entries in the old raw-URL key format are dropped on load, so the next write removes them from disk. +- [ ] With `minimumSupportedVersion` set, a rejecting `checkVersionCompatibility()` no longer aborts `run()`; the failure is logged at debug. +- [ ] Live latest below the minimum: detailed result carries `liveBelowMinimum: true` and `resolveSupportedInstallVersion` returns the minimum; checks off / lookup failed still return `'latest'`. +- [ ] A test proves an `https://` registry uses the user `.npmrc` `https-proxy` (CONNECT to the registry host) and ignores a dead `HTTP_PROXY`/`HTTPS_PROXY`. +- [ ] spec.md §1 describes the real cache shape. + +## Global Constraints + +- ES modules, `.js` import extensions, no `any`, `logger` not `console`. +- Commit messages: Conventional Commits (scopes `agents`, `utils`, ...), ending with `Generated with AI\n\nCo-Authored-By: codemie-ai `. +- Commit with env `CODEMIE_SKIP_SECRETS_SCAN=1`; never `--no-verify`; do not push. +- Never stage `.codemie/codemie-cli.config.json` or `docs/superpowers/reviews/`. +- Every new test must be shown failing against the pre-fix code. + +## Review Focus + +- Registry URL that `new URL()` cannot parse — key must still be produced (origin placeholder `invalid-registry`), never the raw string. Test in Task 1. +- Secret in the failure path (`failures` map), not just `packages`. Test in Task 1. +- A valid new-format key must survive load (no wipe of the whole cache). Covered by Task 1's existing tests once `KEY` is computed. +- `run()` logging before its local `logger` declaration (TDZ) — logging only inside `blockIfBelowMinimum`. Task 2. +- Other fallback `toEqual` cases (checks off, prerelease, failed lookup) must not gain the new field. Task 3 keeps the existing assertions unchanged. + +--- + +### Task 1: Non-secret version-cache key (CR-005) and spec §1 (CR-004) + +**Files:** +- Modify: `src/utils/version-cache.ts:1-20` (imports), `:61-85` (`loadCache`), `:131-133` (key) +- Modify: `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md:48-50` +- Test: `src/utils/__tests__/version-cache.test.ts` + +**Interfaces:** +- Produces: `export function versionCacheKey(registry: string, packageName: string): string` + +Test-first: yes — a registry `https://user:s3cret@npm.example.com/tok-SECRET123/` written via both a successful and a failed lookup leaves neither `s3cret` nor `tok-SECRET123` in the cache file; a seeded legacy key `https://u:s3cret@npm.example.com/|` is gone after the next write. + +- [ ] **Step 1: Write the failing tests.** In `version-cache.test.ts` replace the literal `KEY` (L25) and the mirror literal (L160) with `versionCacheKey(, PKG)`. Add three tests: (a) success path with the secret registry (`state.registry`), read the written file, assert `not.toContain('s3cret')` / `'tok-SECRET123'` / `'user:'`; (b) same for the failure path (fetch mock returns `null`); (c) seed `packages` with the legacy key plus a fresh new-format entry, trigger a write for another package, assert the legacy key and secret are absent and the new-format entry remains. Add (d) unparsable registry `'not a url ${TOKEN}'` → key starts with `invalid-registry#`. +- [ ] **Step 2: Run** `npx vitest run --project unit src/utils/__tests__/version-cache.test.ts` — expect FAIL (`versionCacheKey` not exported; secret present). +- [ ] **Step 3: Implement.** + +```ts +import { createHash } from 'node:crypto'; + +const KEY_PATTERN = /^[^|#\s]+#[0-9a-f]{64}\|/; + +/** Cache key that never embeds the resolved registry URL (it may carry a token). */ +export function versionCacheKey(registry: string, packageName: string): string { + let origin = 'invalid-registry'; + try { + origin = new URL(registry).origin; // origin excludes userinfo, path and query + } catch { + // keep the placeholder + } + const hash = createHash('sha256').update(registry).digest('hex'); + return `${origin}#${hash}|${packageName}`; +} +``` + + In `loadCache`, skip any `packages`/`failures` key not matching `KEY_PATTERN` (comment: legacy raw-URL keys may hold secrets; dropping them lets the next save scrub the file). At L133 use `versionCacheKey(resolveRegistry(packageName), packageName)`. +- [ ] **Step 4: Spec.** Replace "Persists `{ [packageName]: { version, fetchedAt } }`" in spec §1 with the shape `{ version: 1, packages: { '|': { version, fetchedAt } }, failures: { '|': failedAt } }`, stating the registry id is the URL origin without userinfo plus a SHA-256 of the full resolved URL, and that old raw-URL keys are dropped on load. +- [ ] **Step 5: Run** the test file again — expect PASS. + +### Task 2: Guard fallback lookup in blockIfBelowMinimum (CR-001) + +**Files:** +- Modify: `src/agents/core/BaseAgentAdapter.ts:510` +- Test: `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` (next to L261-272) + +Test-first: yes — `adapterFor('2.1.230')` (default claude metadata, minimum `2.1.208`) with `checkVersionCompatibility` always rejecting and `warnOnceIfUntested` rejecting `'stop after version checks'`: `run([])` rejects with `'stop after version checks'`, `warnOnceIfUntested` called with `undefined`, `process.exit` not called. + +- [ ] **Step 1:** Add the test; run `npx vitest run --project unit src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — expect FAIL (rejects with the lookup error). +- [ ] **Step 2:** At L510, when `precomputed` is absent, `await this.checkVersionCompatibility()` inside try/catch; on failure `logger.debug('[BaseAgentAdapter] minimum-version check failed, continuing launch', { agent: this.metadata.name, error: String(error) })` (module-level `logger`) and `return`. Do not add logging in `run()`. +- [ ] **Step 3:** Re-run — expect PASS. + +### Task 3: Install the minimum when live latest is below it (CR-002) + +**Files:** +- Modify: `src/agents/core/version-resolution.ts:80-93`, `:129-136`, `:144-152` +- Test: `src/agents/core/__tests__/version-resolution.test.ts:134-140`, `:206-218` + +**Interfaces:** +- Produces: `ResolvedSupportedVersion.liveBelowMinimum?: true` — set only by the below-minimum branch. + +Test-first: yes — live `0.154.0` below minimum: detailed result `toEqual({ version: , isCurrent: false, liveBelowMinimum: true })`, and `resolveSupportedInstallVersion` returns the `minimumSupportedVersion`. + +- [ ] **Step 1:** Update the L134-140 expectation to include `liveBelowMinimum: true`; add the install-target test beside L206-218 (existing `'latest'` cases stay). Run `npx vitest run --project unit src/agents/core/__tests__/version-resolution.test.ts` — expect FAIL. +- [ ] **Step 2:** Add the optional documented field to the interface; in the below-minimum branch return `{ ...fallback, liveBelowMinimum: true }`; in `resolveSupportedInstallVersion` return `input.minimumSupportedVersion` when `result.liveBelowMinimum && input.minimumSupportedVersion`, else the existing rule. Update its JSDoc. +- [ ] **Step 3:** Re-run — expect PASS. + +### Task 4: https-proxy CONNECT test (CR-003) + +**Files:** +- Test: `src/utils/__tests__/npm-registry.test.ts` (near the proxy tests, L212-245) + +Test-first: no — test-only coverage of existing behaviour; prove it can fail by temporarily removing `npmSetting('https-proxy')` from `proxyAgentFor` (`src/utils/npm-registry.ts:89-104`), observing the failure, then reverting. + +- [ ] **Step 1:** Register `server.on('connect', (req, socket) => { connectTargets.push(req.url ?? ''); socket.destroy(); })` on the existing local server (reset `connectTargets` in `beforeEach`). Test: user `.npmrc` with `registry=https://registry.example.invalid/` and `https-proxy=http://127.0.0.1:/` only; set `HTTP_PROXY`/`HTTPS_PROXY` to `http://127.0.0.1:1` (dead); `fetchLatestVersionFromRegistry(pkg, { timeoutMs: 2000 })` resolves `null`, and `connectTargets` equals `['registry.example.invalid:443']`. Run `npx vitest run --project unit src/utils/__tests__/npm-registry.test.ts` — PASS; perform the mutation check above, then revert. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json new file mode 100644 index 000000000..fa2a9d78d --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json @@ -0,0 +1 @@ +{"standards_review":[{"kind":"commit-format","status":"pass","notes":"All 51 subjects in 2b084ac..HEAD use an allowed type (feat/fix/docs/style/refactor/test) and an allowed scope (agents/utils/cli/config/kimi/tests), are imperative, and are under the 100-char subject-max-length per git-workflow.md"},{"kind":"code-quality","status":"pass","notes":"New exported functions in version-resolution.ts, version-cache.ts, npm-registry.ts and version-utils.ts have explicit return types and JSDoc; imports use .js extensions or the @/ alias; no require() or any added. console.log additions in install.ts/update.ts/setup.ts are chalk-formatted CLI user output, not debug output. The generic Error in setup.ts:781 and the ~100-line checkAgentForUpdate in update.ts:41 predate the base (only the timeout constant and internals changed), so they are not introduced by this change. BaseAgentAdapter.ts (1445 lines) was already over the 500-line file guideline before this change"},{"kind":"security","status":"pass","notes":"npm-registry.ts reads only the user .npmrc (project .npmrc and npm-exported npm_config_* ignored under npm), so a checked-out repo cannot redirect the registry or proxy or exfiltrate env vars; version-cache.ts keys by URL origin plus a SHA-256 hash so registry credentials never reach the cache file, and legacy raw-URL keys are dropped on load; registry responses are size-capped (1 MB) and validated against a strict version regex before being cached or used as an install target; no secrets, registry URLs or proxy URLs are logged; no attribution headers touched"}],"blocking_findings":[],"coverage_gap":false} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md new file mode 100644 index 000000000..be9a675a6 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md @@ -0,0 +1,116 @@ +# Technical Research + +**Task**: version-cache npm-registry version-resolution BaseAgentAdapter +**Generated**: 2026-10-07 +**Research path**: codegraph + +--- + +## 1. Original Context + +Repo C:\Users\Yauheni_Hil\repos\codemie-code, branch feat/agents-live-version-check (PR #576, EPMCDME-14767, live npm version tracking). Fix five code-review findings (round-4 review: docs/superpowers/reviews/2026-10-07-pr576-live-version-4/code-review-final.json). Commit on the current branch; do not push. Do not touch .codemie/codemie-cli.config.json or docs/superpowers/reviews/. + +1. CR-005 (security) src/utils/version-cache.ts:133 — cache key is `${resolveRegistry(pkg)}|${pkg}`; resolveRegistry returns the env-expanded user .npmrc registry, so a token in the URL (${NPM_TOKEN} or user:pass@ userinfo) is written in plaintext to ~/.codemie/version-cache.json. Fix: key by a non-secret identifier — URL origin without userinfo plus a SHA-256 hash of the full resolved registry URL. Same key for packages and failures maps. Test: a registry URL containing a secret never appears in the written cache file. +2. CR-001 src/agents/core/BaseAgentAdapter.ts ~line 510 — run() swallows a rejected shared checkVersionCompatibility() and passes undefined, but blockIfBelowMinimum then calls checkVersionCompatibility() again unguarded; all live-tracked agents have minimumSupportedVersion, so a failure still aborts launch, contradicting the comment in run(). Fix: in blockIfBelowMinimum, catch the fallback lookup and return (continue launch) if it fails. Note: run() redeclares a local `logger` later (const { logger } = await import(...)), so referencing logger in run() before that line throws (TDZ). Test: run() with default claude metadata (minimum set) whose checkVersionCompatibility always rejects → launch continues (pattern in src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts: spy warnOnceIfUntested to reject with 'stop after version checks'). +3. CR-002 src/agents/core/version-resolution.ts:~129 — when live latest is below minimumSupportedVersion the resolver returns fallback isCurrent:false, so resolveSupportedInstallVersion returns 'latest' and install --supported installs that same below-minimum release from the lagging registry. Fix: resolveSupportedInstallVersion returns minimumSupportedVersion in that specific case (live below minimum), still 'latest' for genuinely unknown (checks off / lookup failed). Distinguish the case in the detailed result (e.g. an extra field) without changing existing callers' behaviour. Test it. +4. CR-003 test only — src/utils/__tests__/npm-registry.test.ts: add a test for an https:// registry with user .npmrc `https-proxy=` only, asserting the proxy receives the CONNECT and a dead HTTP_PROXY/HTTPS_PROXY env is not used. Existing tests use a local http server and set CODEMIE_NO_SYSTEM_PROXY=1; a CONNECT handler (server.on('connect')) can record the target and close the socket. +5. CR-004 spec only — docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md §1: describe the real cache shape: { version: 1, packages: { '|': { version, fetchedAt } }, failures: { '|': failedAt } }, keyed by a non-secret registry id. + +Project rules: ES modules with .js imports, no `any`, logger not console, Conventional Commits (scopes: agents, utils, cli...), commit message ends with "Generated with AI\n\nCo-Authored-By: codemie-ai ". Commits need env CODEMIE_SKIP_SECRETS_SCAN=1 (no Docker); never --no-verify. Validate with npm run typecheck, eslint on changed files, npx vitest run --project unit and --project cli (bash, Windows host). Prove each new test fails against the pre-fix code. + +--- + +## 2. Codebase Findings + +### Existing Implementations +- `src/utils/version-cache.ts` (162 lines) — `getCachedLatestVersion(pkg, {bypassCache})`. L133 `const key = \`${resolveRegistry(packageName)}|${packageName}\``; same `key` used for `cache.packages[key]` (L136, L158), `cache.failures[key]` (L138, L152, L159). Imports `resolveRegistry` from `./npm-registry.js`. `CacheFile = { version: 1; packages: Record; failures: Record }`. `loadCache` keeps only well-formed entries; no `crypto` import yet. +- `src/utils/npm-registry.ts` — `readNpmrc` expands `${VAR}` from env (L38); `resolveRegistry(pkg)` (L78) returns `@scope:registry` / `registry` / default `https://registry.npmjs.org/`, always trailing `/`. `proxyAgentFor` (L89-104): NO_PROXY/noproxy first; for https: `npmSetting('https-proxy') || npmSetting('proxy')` -> `new HttpsProxyAgent(npmProxy)`; without npm proxy -> `getProxyAgentForUrl` (env vars then system proxy/PAC). `fetchLatestVersionFromRegistry(pkg, {timeoutMs})` returns null on any failure. +- `src/agents/core/version-resolution.ts` — `ResolvedSupportedVersion { version; isCurrent }` (L80-93). `resolveSupportedVersionDetailed` (L103-142): below-minimum branch L129-136 returns the shared `fallback` object (same as checks-off/failed/prerelease). `resolveSupportedInstallVersion` (L149-152): `isCurrent && version ? version : 'latest'`. +- `src/agents/core/BaseAgentAdapter.ts` — `checkVersionCompatibility()` L294 (destructures `{version, isCurrent}` only); `warnOnceIfUntested(precomputed?)` L421, whole body in try/catch; `blockIfBelowMinimum(precomputed?)` L505-545: returns early if no supportedVersion/minimum, then L510 `precomputed ?? await this.checkVersionCompatibility()` unguarded; throws in silentMode, else `process.exit(1)`. `run()` L550-565: `.catch(() => undefined)` on shared check; comment L558-560 documents the TDZ; `const { logger } = await import(...)` at L599 shadows the module-level `logger` (imported L6). `blockIfBelowMinimum` itself is outside run(), so module `logger` is usable there. +- `installVersion('supported')` callers of `resolveSupportedInstallVersion`: `BaseAgentAdapter.installVersion` L188-222 (npm `installGlobal`, codex/gemini), `ClaudePlugin.installVersion` (claude.plugin.ts:746, native installer), `KimiPlugin.installVersion` (kimi.plugin.ts:342), plus `src/cli/commands/setup.ts`. All pass `minimumSupportedVersion`. + +### Architecture and Layers Affected +- Utils layer: `version-cache.ts`, `npm-registry.ts` (test only). +- Agent core layer: `version-resolution.ts`, `BaseAgentAdapter.ts`. +- Docs: spec.md §1 (L46-59 currently says `{ [packageName]: { version, fetchedAt } }`, L48-50). + +### Integration Points +- `checkVersionCompatibility` has 7 callers (doctor AgentsCheck, setup, install, BaseAgentAdapter); `resolveSupportedVersionDetailed` result is destructured, so an added optional field is non-breaking at runtime. +- `resolveSupportedInstallVersion` has 9 callers; agent plugins' tests mock it. + +### Patterns and Conventions +- `@/` alias imports with `.js` in version-resolution.ts; relative `./x.js` in utils. Error-swallowing helpers log via `logger.warn/debug` with `{ error: String(error) }`. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs +- `.ai-run/guides/security/security-practices.md` — cited by review: never store tokens in plaintext files. +- `.ai-run/guides/testing/testing-patterns.md` — Vitest, dynamic-import mocking. + +### Architectural Decisions +- npm-registry.ts L63-66 comment: project .npmrc ignored because registry URLs may embed `${TOKEN}`; this is the leak vector CR-005 names. +- run() comment L555-560: version-check failure must never stop launch. + +### Derived Conventions +- Version-resolution "unknown" states all collapse to `isCurrent:false`; callers treat it as "no supported version configured". + +--- + +## 4. Testing Landscape + +### Existing Coverage +- `src/utils/__tests__/version-cache.test.ts` — mocks `../npm-registry.js` (`resolveRegistry: () => state.registry`), `../paths.js`, logger; hardcodes `KEY = \`https://registry.npmjs.org/|${PKG}\`` (L25) used by seedCache, assertions at L59, 102, 114, 122, 135, 140, 159, and a mirror key literal at L160. Has a registry-isolation test (L151-161). +- `src/utils/__tests__/npm-registry.test.ts` — one local `http` server (`createServer`, 127.0.0.1); `ENV_KEYS` saved/cleared incl. `HTTP(S)_PROXY`, `npm_config_https_proxy`, `CODEMIE_NO_SYSTEM_PROXY=1`; `writeUserNpmrc` helper; proxy tests use only `http://registry.example.invalid/` and `proxy=` key (L212-245). No CONNECT handler, no https registry test. +- `src/agents/core/__tests__/version-resolution.test.ts` — L134-140 asserts below-minimum result `toEqual({ version: '0.154.0', isCurrent: false })`; `resolveSupportedInstallVersion` tests L206-218 (live, null -> 'latest'). No below-minimum install test. +- `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — mocks version-resolution; default metadata = claude with `minimumSupportedVersion: '2.1.208'`; L261-272 tests rejected shared check only with `minimumSupportedVersion: undefined`. + +### Testing Framework and Patterns +- Vitest; `vi.hoisted` state, `vi.mock` of relative paths, `vi.spyOn(adapter, ...)`; projects `unit` and `cli`. + +### Coverage Gaps +- run() with minimum set and a rejecting check (CR-001). +- https-proxy branch / CONNECT path (CR-003). +- Secret absence in cache file (CR-005); below-minimum install target (CR-002). + +--- + +## 5. Configuration and Environment + +### Environment Variables +- `npm_config_registry`, `npm_config_userconfig`, `npm_config_https_proxy`, `npm_config_proxy`, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, `CODEMIE_NO_SYSTEM_PROXY`, `CODEMIE_VERSION_CHECKS_ENABLED`, `CODEMIE_SKIP_SECRETS_SCAN` (commit hook). + +### Configuration Files +- `~/.codemie/version-cache.json` via `getCodemiePath('version-cache.json')`; user `~/.npmrc`. + +### Feature Flags and Deployment Concerns +- `workspace.versionChecks.enabled` toggle (checks off -> fallback, no lookup). + +--- + +## 6. Risk Indicators + +- `version-cache.test.ts` hardcodes the raw-URL key in ~9 places; any key-format change breaks them all (needs a shared key helper or computed KEY in the test). +- Existing cache files contain old raw-URL keys (possibly with secrets); `loadCache` keeps any string key, so old entries persist on disk until overwritten — the review recommends "migrate/ignore old keys". Speculative: stripping non-matching keys on load/save would be needed to scrub already-leaked secrets. +- `version-resolution.test.ts` L139 uses `toEqual` on the below-minimum result; adding a field there changes that assertion. Other fallback `toEqual` cases stay intact only if the new field is absent on them. +- CR-003 test: HTTPS through `HttpsProxyAgent` issues CONNECT to the local http server; `server.on('connect')` must be added in this shared server (or a second server) and the socket destroyed so the fetch resolves null within timeout. `HTTPS_PROXY` is in ENV_KEYS already. +- BaseAgentAdapter TDZ: any logging added inside run() before L599 throws; logging belongs in `blockIfBelowMinimum` (module logger). +- Speculative: CR-002 path for claude/kimi native installer passes the minimum version to `installNativeAgent`; plugin tests mock the resolver so are unaffected. + +--- + +## 7. Summary for Complexity Assessment + +Changes span two layers: utils (`version-cache.ts` key derivation; `npm-registry.test.ts` new test) and agent core (`version-resolution.ts` result discriminator + install target; `BaseAgentAdapter.blockIfBelowMinimum` guard), plus a spec.md §1 text edit. Each fix is local to one function with an already-identified location; roughly 4 source/doc files and 4 test files. + +Novelty is low: SHA-256 via `node:crypto` for the key, a `.catch` guard mirroring run(), an optional field on `ResolvedSupportedVersion`. The CR-003 test is the most mechanical-risk item (HTTPS CONNECT against a plain http test server). All four touched modules have dedicated test files with established mocking patterns. + +Key risks: brittle hardcoded cache keys in version-cache tests, a `toEqual` assertion on the below-minimum result, legacy secret-bearing keys already on disk, and the run() logger TDZ. + +--- + +## 8. External References + +- `docs/superpowers/reviews/2026-10-07-pr576-live-version-4/code-review-final.json` — resolved. CR-005 recommendation: "Key the cache by a non-secret registry identifier — e.g. URL origin plus a SHA-256 of the full resolved URL, or the unexpanded registry string — stripping userinfo; migrate/ignore old keys, and add a test asserting no env-expanded value reaches the file." CR-001: "guard the fallback lookup (e.g. `const compat = precomputed ?? await this.checkVersionCompatibility().catch(() => undefined); if (!compat) return;`) ... log the swallowed error". CR-002: "abort with a clear error ... or install the minimum instead of falling back to 'latest'" (task chose: install minimum). CR-003: "https:// registry and a user .npmrc containing only https-proxy=, asserting the proxy receives the CONNECT and that a dead HTTP_PROXY is not used." CR-004: amend spec §1 to the registry-scoped shape with failures map. +- `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` §1 — resolved; L48-50 text to replace: "Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under `~/.codemie/`". diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 9c5d4b336..93358a99f 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -35,6 +35,11 @@ import { VersionWarningStore } from '../../utils/version-warnings.js'; import { getCurrentCliVersion } from '../../utils/cli-updater.js'; import { applySystemProxyEnvironment } from '../../utils/system-proxy.js'; import { installSystemProxyDispatcher } from '../../utils/system-proxy-dispatcher.js'; +import { + isAheadOfLiveTracking, + resolveSupportedInstallVersion, + resolveSupportedVersionDetailed, +} from './version-resolution.js'; /** * Base class for all agent adapters @@ -188,10 +193,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Resolve 'supported' to actual version from metadata let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!this.metadata.supportedVersion) { - throw new Error(`${this.displayName}: No supported version defined in metadata`); - } - resolvedVersion = this.metadata.supportedVersion; + resolvedVersion = await resolveSupportedInstallVersion({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, + }); logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, @@ -285,8 +292,16 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * @returns Version compatibility result with status and version info */ async checkVersionCompatibility(): Promise { - const supportedVersion = this.metadata.supportedVersion || 'latest'; + const { version: resolved, isCurrent, liveBelowMinimum, registryLatestVersion } = await resolveSupportedVersionDetailed({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, + }); + const versionKnown = Boolean(isCurrent && resolved); + const supportedVersion = isCurrent && resolved ? resolved : 'latest'; const minimumSupportedVersion = this.metadata.minimumSupportedVersion; + const belowMinimum = liveBelowMinimum ? { liveBelowMinimum: true, registryLatestVersion } : {}; const installedVersion = await this.getVersion(); @@ -306,18 +321,33 @@ export abstract class BaseAgentAdapter implements AgentAdapter { hasUpdate: false, isBelowMinimum: false, minimumSupportedVersion, + versionKnown, + ...belowMinimum, }; } - if (!this.metadata.supportedVersion) { + // The minimum is a maintainer-pinned hard gate, so it must hold even when + // the tracked version is unknown (checks off, offline). + let isBelowMinimum = false; + if (minimumSupportedVersion) { + try { + isBelowMinimum = compareVersions(installedVersion, minimumSupportedVersion) < 0; + } catch { + isBelowMinimum = false; + } + } + + if (!versionKnown) { return { compatible: true, installedVersion, supportedVersion: 'latest', isNewer: false, hasUpdate: false, - isBelowMinimum: false, + isBelowMinimum, minimumSupportedVersion, + versionKnown, + ...belowMinimum, }; } @@ -325,12 +355,6 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const comparison = compareVersions(installedVersion, supportedVersion); const hasUpdate = comparison < 0; - let isBelowMinimum = false; - if (minimumSupportedVersion) { - const minimumComparison = compareVersions(installedVersion, minimumSupportedVersion); - isBelowMinimum = minimumComparison < 0; - } - logger.debug('Version comparison result', { agent: this.metadata.name, comparison, @@ -351,6 +375,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { hasUpdate, isBelowMinimum, minimumSupportedVersion, + versionKnown, }; } catch (error) { const errorContext = createErrorContext(error, { agent: this.metadata.name }); @@ -381,31 +406,39 @@ export abstract class BaseAgentAdapter implements AgentAdapter { supportedVersion, isNewer: false, hasUpdate: false, - isBelowMinimum: false, + isBelowMinimum, minimumSupportedVersion, + versionKnown, }; } } /** * Emit a one-time notice when the installed version differs from the - * recommended `metadata.supportedVersion`, then record the marker so later - * launches stay silent until the recommendation itself moves. + * latest tracked `metadata.supportedVersion`, then record the marker so later + * launches stay silent until the tracked version itself moves. * * Never prompts, never blocks, never throws — a failure to read or write the * marker store must not stop the agent from launching. */ - async warnOnceIfUntested(): Promise { + async warnOnceIfUntested(precomputed?: VersionCompatibilityResult): Promise { try { if (!this.metadata.supportedVersion) { return; } - const compat = await this.checkVersionCompatibility(); + const compat = precomputed ?? await this.checkVersionCompatibility(); + // Checks off or lookup failed: behave as if no version were configured. + if (compat.versionKnown === false) { + return; + } const { installedVersion, supportedVersion } = compat; if (!installedVersion || installedVersion === supportedVersion) { return; } + if (isAheadOfLiveTracking(this.metadata.name, compat)) { + return; + } let alreadyWarned = false; try { @@ -426,7 +459,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const codemieVersion = (await getCurrentCliVersion()) ?? 'unknown'; const notice = - `CodeMie recommends ${this.displayName} v${supportedVersion}; ` + + `CodeMie is tracking ${this.displayName} v${supportedVersion}; ` + `you are running v${installedVersion} (CodeMie v${codemieVersion}).`; logger.warn(notice, { @@ -441,7 +474,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { if (!this.metadata.silentMode && !isNonInteractiveEnvironment()) { console.error(); console.error(chalk.yellow(`⚠ ${notice}`)); - console.error(chalk.white(' Continuing. To switch to the recommended version, run:')); + console.error(chalk.white(' Continuing. To switch to the tracked version, run:')); console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); console.error(); } @@ -470,14 +503,26 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * This is the only remaining hard gate: `minimumSupportedVersion` marks * versions with known protocol breaks, where launching produces corrupted * output rather than a degraded experience. Everything above the minimum is - * a recommendation handled by {@link warnOnceIfUntested}. + * tracked, non-blocking guidance handled by {@link warnOnceIfUntested}. */ - private async blockIfBelowMinimum(): Promise { + private async blockIfBelowMinimum(precomputed?: VersionCompatibilityResult): Promise { if (!this.metadata.supportedVersion || !this.metadata.minimumSupportedVersion) { return; } - const compat = await this.checkVersionCompatibility(); + let compat = precomputed; + if (!compat) { + try { + compat = await this.checkVersionCompatibility(); + } catch (error) { + // An unknown installed version is not a known-broken one: launch rather than block. + logger.debug('[BaseAgentAdapter] minimum-version check failed, continuing launch', { + agent: this.metadata.name, + error: String(error), + }); + return; + } + } if (!compat.isBelowMinimum) { return; } @@ -503,10 +548,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { console.error(); console.error(chalk.red(`✗ ${this.displayName} v${installedDisplay} is no longer supported`)); console.error(chalk.red(` Minimum required version: v${minimumDisplay}`)); - console.error( - chalk.white(` Recommended version: v${compat.supportedVersion} `) + - chalk.green('(recommended)') - ); + if (compat.versionKnown !== false) { + console.error(chalk.white(` Latest tracked version: v${compat.supportedVersion}`)); + } console.error(); console.error(chalk.white(' This version is known to be incompatible with CodeMie.')); console.error(chalk.white(' Upgrade with:')); @@ -524,9 +568,16 @@ export abstract class BaseAgentAdapter implements AgentAdapter { runOptions?: { dryRun?: boolean }, ): Promise { // Version handling (EPMCDME-13734): known-broken versions are refused, - // everything else is a one-time recommendation — no prompts, no re-nagging. - await this.blockIfBelowMinimum(); - await this.warnOnceIfUntested(); + // everything else gets a one-time notice — no prompts, no re-nagging. + // Resolve once and share: each check would otherwise do its own live lookup, + // doubling the wait on every offline launch. A failure here must never stop the launch: + // both checks then fall back to their own guarded lookup, which logs it. (No logging here: + // `logger` is redeclared later in run(), so referencing it in this handler would throw.) + const compat = this.metadata.supportedVersion + ? await this.checkVersionCompatibility().catch(() => undefined) + : undefined; + await this.blockIfBelowMinimum(compat); + await this.warnOnceIfUntested(compat); // Generate session ID at the very start - this is the source of truth // All components (logger, metrics, proxy) will use this same session ID diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 63fc4f622..ad1cfad2b 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -55,6 +55,21 @@ vi.mock('../../../utils/interactive.js', () => ({ isNonInteractiveEnvironment: vi.fn(() => false), })); +// Tracked version resolves to the metadata value as if confirmed live; flip +// `isCurrent` to simulate checks disabled / lookup failure. +const versionResolution = vi.hoisted(() => ({ isCurrent: true, liveVersion: undefined as string | undefined })); +vi.mock('../version-resolution.js', () => ({ + isAheadOfLiveTracking: vi.fn( + (name: string, compat: { isNewer?: boolean }) => + Boolean(compat.isNewer) && ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].includes(name) + ), + resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), + resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ + version: versionResolution.liveVersion ?? fallbackSupportedVersion, + isCurrent: versionResolution.isCurrent, + })), +})); + const metadata = (overrides: Partial = {}): AgentMetadata => ({ name: 'claude', displayName: 'Claude Code', @@ -83,9 +98,68 @@ async function adapterFor( describe('warnOnceIfUntested', () => { beforeEach(() => { vi.clearAllMocks(); + versionResolution.isCurrent = true; + versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + it('notices against the live tracked version, not the pinned fallback', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.liveVersion = '2.1.300'; + // Installed equals the metadata fallback, so only a live-based comparison produces a notice. + const adapter = await adapterFor('2.1.218'); + + await adapter.warnOnceIfUntested(); + + expect(VersionWarningStore.recordWarning).toHaveBeenCalledWith('claude', '2.1.218', '2.1.300', '0.15.1'); + const printed = vi.mocked(console.error).mock.calls.flat().join('\n'); + expect(printed).toContain('CodeMie is tracking Claude Code v2.1.300'); + }); + + it('stays silent when the installed version matches the live tracked version', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.liveVersion = '2.1.300'; + const adapter = await adapterFor('2.1.300'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + + it('stays silent when the tracked version is unknown (checks off or lookup failed)', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.isCurrent = false; + const adapter = await adapterFor('2.1.230'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + + it('does not advise a downgrade when a live-tracked agent is ahead of the cached tracked version', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + const adapter = await adapterFor('2.1.230'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + + it('still notices an agent outside live tracking that is ahead of its pinned version', async () => { + const adapter = await adapterFor('1.0.90', { + name: 'copilot-cli', + supportedVersion: '1.0.83', + minimumSupportedVersion: '1.0.79', + }); + + await adapter.warnOnceIfUntested(); + + expect(console.error).toHaveBeenCalled(); + }); + it('stays silent when the installed version is the recommended one', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); const adapter = await adapterFor('2.1.218'); @@ -98,14 +172,14 @@ describe('warnOnceIfUntested', () => { it('notices a mismatch once and records the baseline it was acknowledged against', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await adapter.warnOnceIfUntested(); expect(console.error).toHaveBeenCalled(); expect(VersionWarningStore.recordWarning).toHaveBeenCalledWith( 'claude', - '2.1.230', + '2.1.212', '2.1.218', '0.15.1' ); @@ -114,7 +188,7 @@ describe('warnOnceIfUntested', () => { it('stays silent once the pair is already acknowledged', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); vi.mocked(VersionWarningStore.hasWarned).mockResolvedValue(true); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await adapter.warnOnceIfUntested(); @@ -123,7 +197,7 @@ describe('warnOnceIfUntested', () => { }); it('writes no banner in silent mode, so the JSON-RPC stream stays clean', async () => { - const adapter = await adapterFor('2.1.230', { silentMode: true }); + const adapter = await adapterFor('2.1.212', { silentMode: true }); await adapter.warnOnceIfUntested(); @@ -134,7 +208,7 @@ describe('warnOnceIfUntested', () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); vi.mocked(VersionWarningStore.hasWarned).mockRejectedValue(new Error('EACCES')); vi.mocked(VersionWarningStore.recordWarning).mockRejectedValue(new Error('EACCES')); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await expect(adapter.warnOnceIfUntested()).resolves.toBeUndefined(); expect(console.error).toHaveBeenCalled(); @@ -144,9 +218,76 @@ describe('warnOnceIfUntested', () => { describe('run() below the minimum supported version', () => { beforeEach(() => { vi.clearAllMocks(); + versionResolution.isCurrent = true; + versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + it('still refuses to launch when the tracked version is unknown', async () => { + versionResolution.isCurrent = false; + const adapter = await adapterFor('2.1.100', { silentMode: true }); + + await expect(adapter.run([])).rejects.toThrow(/below the minimum supported version/); + }); + + it('omits the "Latest tracked version" line when the tracked version is unknown', async () => { + versionResolution.isCurrent = false; + const adapter = await adapterFor('2.1.100'); + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + await expect(adapter.run([])).rejects.toThrow('process.exit called'); + + const printed = vi.mocked(console.error).mock.calls.flat().join('\n'); + expect(printed).toContain('Minimum required version'); + expect(printed).not.toContain('Latest tracked version'); + expect(printed).not.toContain('vlatest'); + }); + + it('resolves version compatibility once and shares it with both checks', async () => { + const adapter = await adapterFor('2.1.230'); + const compatSpy = vi.spyOn(adapter, 'checkVersionCompatibility'); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + + expect(compatSpy).toHaveBeenCalledTimes(1); + expect(noticeSpy).toHaveBeenCalledWith(await compatSpy.mock.results[0].value); + }); + + it('keeps launching when the shared version check fails for an agent without a minimum', async () => { + // Without a minimum there is no hard gate, so a failing advisory check must not stop run(). + const adapter = await adapterFor('2.1.230', { minimumSupportedVersion: undefined }); + vi.spyOn(adapter, 'checkVersionCompatibility').mockRejectedValue(new Error('lookup blew up')); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + + // Reaching the notice step proves the failed check did not abort run(). + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + expect(noticeSpy).toHaveBeenCalledWith(undefined); + }); + + it('keeps launching when the version check fails for an agent with a minimum', async () => { + // The minimum gate re-runs the check when the shared one failed; a second failure must not + // abort run() either, since the installed version is unknown rather than known-broken. + const adapter = await adapterFor('2.1.230'); + vi.spyOn(adapter, 'checkVersionCompatibility').mockRejectedValue(new Error('lookup blew up')); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + expect(noticeSpy).toHaveBeenCalledWith(undefined); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('throws in silent mode so ACP callers get a structured error', async () => { const adapter = await adapterFor('2.1.100', { silentMode: true }); diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts new file mode 100644 index 000000000..cf06c4b72 --- /dev/null +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const getCachedLatestVersion = vi.hoisted(() => vi.fn()); +const loadLocal = vi.hoisted(() => vi.fn()); +const loadGlobal = vi.hoisted(() => vi.fn()); + +vi.mock('../../../utils/version-cache.js', () => ({ getCachedLatestVersion })); +vi.mock('../../../utils/config.js', () => ({ + ConfigLoader: { loadLocalMultiProviderConfig: loadLocal, loadMultiProviderConfig: loadGlobal }, +})); +vi.mock('../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + isAheadOfLiveTracking, + isLiveTrackedAgent, + isVersionChecksEnabled, + resolveSupportedInstallVersion, + resolveSupportedVersionDetailed, +} from '../version-resolution.js'; +import { AgentInstallationError } from '../../../utils/errors.js'; + +const input = { + agentName: 'codex', + npmPackage: '@openai/codex', + fallbackSupportedVersion: '0.154.0', +}; + +const scope = (enabled?: unknown) => ({ + version: 2, + profiles: {}, + workspace: enabled === undefined ? {} : { versionChecks: { enabled } }, +}); + +function checksOff(): void { + loadGlobal.mockResolvedValue(scope(false)); +} + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; + loadLocal.mockResolvedValue({ version: 2, profiles: {} }); + loadGlobal.mockResolvedValue({ version: 2, profiles: {} }); +}); + +afterEach(() => { + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; +}); + +describe('isVersionChecksEnabled', () => { + it('defaults to enabled when nothing is configured', async () => { + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('honours a global false even when the project has its own workspace block', async () => { + loadLocal.mockResolvedValue(scope(undefined)); + loadGlobal.mockResolvedValue(scope(false)); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + + it('lets the project setting override the global one', async () => { + loadLocal.mockResolvedValue(scope(true)); + loadGlobal.mockResolvedValue(scope(false)); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('honours the env var even when the config cannot be loaded (e.g. no active profile)', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + loadLocal.mockRejectedValue(new Error('No active profile set')); + loadGlobal.mockRejectedValue(new Error('No active profile set')); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + + it('treats an empty env var as unset, so a config false still applies', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = ''; + checksOff(); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + + it('lets the env var override the config', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'true'; + checksOff(); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('treats an unrecognized value as enabled', async () => { + loadGlobal.mockResolvedValue(scope('nope')); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('stays enabled when every config read fails', async () => { + loadLocal.mockRejectedValue(new Error('corrupt')); + loadGlobal.mockRejectedValue(new Error('corrupt')); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); +}); + +describe('isLiveTrackedAgent', () => { + it('tracks the ticket agents and kimi-acp, but not copilot-cli', () => { + expect(['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].every(isLiveTrackedAgent)).toBe(true); + expect(isLiveTrackedAgent('copilot-cli')).toBe(false); + }); +}); + +describe('isAheadOfLiveTracking', () => { + it('is true only for a live-tracked agent installed ahead of the tracked version', () => { + expect(isAheadOfLiveTracking('claude', { isNewer: true })).toBe(true); + expect(isAheadOfLiveTracking('claude', { isNewer: false })).toBe(false); + expect(isAheadOfLiveTracking('claude', {})).toBe(false); + }); + + it('is false for an agent with a maintainer-pinned version, which keeps its notice', () => { + expect(isAheadOfLiveTracking('copilot-cli', { isNewer: true })).toBe(false); + }); +}); + +describe('resolveSupportedVersionDetailed', () => { + it('reports a successful npm lookup as live', async () => { + getCachedLatestVersion.mockResolvedValue('0.160.0'); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.160.0', + isCurrent: true, + }); + }); + + it('is not live when the registry reports a latest below the minimum (lagging mirror)', async () => { + getCachedLatestVersion.mockResolvedValue('0.140.0'); + + await expect( + resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) + ).resolves.toEqual({ + version: '0.154.0', + isCurrent: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }); + }); + + it('is live when the registry latest equals the minimum', async () => { + getCachedLatestVersion.mockResolvedValue('0.143.0'); + + await expect( + resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) + ).resolves.toEqual({ version: '0.143.0', isCurrent: true }); + }); + + it('is not live when version checks are disabled, and skips the lookup', async () => { + checksOff(); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isCurrent: false, + }); + expect(getCachedLatestVersion).not.toHaveBeenCalled(); + }); + + it('is not live when the lookup fails', async () => { + getCachedLatestVersion.mockRejectedValue(new Error('offline')); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isCurrent: false, + }); + }); + + it('is not live when the lookup returns nothing', async () => { + getCachedLatestVersion.mockResolvedValue(null); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toMatchObject({ isCurrent: false }); + }); + + it('is not live when npm reports a prerelease', async () => { + getCachedLatestVersion.mockResolvedValue('0.161.0-beta.1'); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isCurrent: false, + }); + }); + + it('keeps the maintainer-pinned version current for agents outside the live-tracked list', async () => { + await expect( + resolveSupportedVersionDetailed({ ...input, agentName: 'copilot-cli', npmPackage: '@github/copilot' }) + ).resolves.toEqual({ version: '0.154.0', isCurrent: true }); + expect(getCachedLatestVersion).not.toHaveBeenCalled(); + }); + + it('reports nothing current for an untracked agent with no pinned version', async () => { + await expect( + resolveSupportedVersionDetailed({ agentName: 'opencode', npmPackage: 'opencode-ai' }) + ).resolves.toEqual({ version: undefined, isCurrent: false }); + }); + + it('treats an untracked agent as unknown too when checks are disabled', async () => { + checksOff(); + + await expect( + resolveSupportedVersionDetailed({ ...input, agentName: 'copilot-cli' }) + ).resolves.toMatchObject({ isCurrent: false }); + }); +}); + +describe('resolveSupportedInstallVersion', () => { + it('installs the live tracked version when known', async () => { + getCachedLatestVersion.mockResolvedValue('0.160.0'); + + await expect(resolveSupportedInstallVersion(input)).resolves.toBe('0.160.0'); + }); + + it('installs the latest channel, not the stale fallback, when the tracked version is unknown', async () => { + getCachedLatestVersion.mockResolvedValue(null); + + await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); + }); + + it('stops with a specific error, not the latest channel, when the registry latest is below the minimum', async () => { + // `latest` would resolve to the same lagging release the minimum gate then refuses to launch. + getCachedLatestVersion.mockResolvedValue('0.140.0'); + + const result = resolveSupportedInstallVersion({ ...input, minimumSupportedVersion: '0.143.0' }); + + await expect(result).rejects.toBeInstanceOf(AgentInstallationError); + await expect(result).rejects.toThrow( + "the registry's latest release v0.140.0 is below the minimum supported v0.143.0 (a lagging mirror?). " + + 'Install a specific version: codemie install codex ' + ); + }); +}); diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index ae718b011..f49d323ac 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -203,6 +203,13 @@ export interface VersionCompatibilityResult { hasUpdate: boolean; // true if newer supported version available (one-time notice) isBelowMinimum: boolean; // true if installed < minimumSupportedVersion (blocks startup) minimumSupportedVersion?: string; // minimum version required to run (from metadata) + // false when checks are off or the live lookup failed: supportedVersion is then 'latest' + // and no "tracking vX" notice may be shown. Optional so older mocks/callers stay valid. + versionKnown?: boolean; + // true when the registry's latest release is below minimumSupportedVersion (e.g. a lagging + // mirror): the tracked version is unknown, and installing it must stop rather than install that release. + liveBelowMinimum?: boolean; + registryLatestVersion?: string; // the rejected registry latest; set with liveBelowMinimum } /** @@ -219,8 +226,11 @@ export interface AgentMetadata { cliCommand: string | null; // 'claude' or null for built-in /** - * Latest version tested with the CodeMie backend — a recommendation, not a - * requirement. A mismatch produces one non-blocking notice per version. + * The version CodeMie tracks as current — not a requirement. For + * live-tracked agents (see `LIVE_TRACKED_AGENT_NAMES`) this is resolved from + * the package's live npm `latest` tag rather than backend-tested; for other + * agents it is this maintainer-pinned fallback. A mismatch produces one + * non-blocking notice per version. * * Format: Semantic version string (e.g., '2.0.30') * Special values: 'latest', 'stable' (channels) @@ -853,9 +863,10 @@ export interface AgentAdapter { /** * Emit a one-time notice when the installed version differs from the * recommended one, and record it so later launches stay silent. Never - * prompts, never blocks, never throws. + * prompts, never blocks, never throws. Pass an already-computed result to + * avoid a second version lookup. */ - warnOnceIfUntested(): Promise; + warnOnceIfUntested(precomputed?: VersionCompatibilityResult): Promise; /** * Detect installation method (optional, for installation-aware agents) diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts new file mode 100644 index 000000000..444c60b20 --- /dev/null +++ b/src/agents/core/version-resolution.ts @@ -0,0 +1,191 @@ +import { getCachedLatestVersion } from '@/utils/version-cache.js'; +import { compareVersions, extractVersion } from '@/utils/version-utils.js'; +import { ConfigLoader } from '@/utils/config.js'; +import { logger } from '@/utils/logger.js'; +import { AgentInstallationError } from '@/utils/errors.js'; +import { getAgentInstallCommand } from './agent-aliases.js'; + +// The ticket's four agents; kimi-acp runs the same package and binary as kimi. +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'] as const; + +/** + * Whether the agent's tracked version follows its npm `latest` release (see + * {@link LIVE_TRACKED_AGENT_NAMES}). Other agents are never looked up live. + * + * @param agentName - agent metadata `name`, e.g. `codex` + */ +export function isLiveTrackedAgent(agentName: string): boolean { + return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); +} + +/** + * Whether a live-tracked agent is installed ahead of its tracked version. That usually means it + * self-updated since the (up to 24h old) cached lookup, so advising `install --supported` there + * would suggest a downgrade; the launch notice and `codemie doctor` stay quiet instead. + * + * @param agentName - agent metadata `name` + * @param compat - the agent's version compatibility result + */ +export function isAheadOfLiveTracking(agentName: string, compat: { isNewer?: boolean }): boolean { + return Boolean(compat.isNewer) && isLiveTrackedAgent(agentName); +} + +export interface ResolveSupportedVersionInput { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; + /** The agent's hard minimum; a live version below it is not treated as current. */ + minimumSupportedVersion?: string; + /** Always query the registry instead of a fresh cache entry (explicit `codemie update`). */ + bypassCache?: boolean; +} + +/** + * Whether the `versionChecks.enabled` toggle permits version checks. + * + * Resolved field by field — `CODEMIE_VERSION_CHECKS_ENABLED`, then the project's + * `workspace.versionChecks`, then the global one — rather than through ConfigLoader.load(), + * because load() swaps in a project's whole `workspace` block (hiding a global setting it + * doesn't repeat) and throws when no profile is active (hiding the env var). Fail-safe: only an + * explicit `false` disables checks; an unreadable config or unrecognized value leaves them on. + */ +export async function isVersionChecksEnabled(workingDir: string = process.cwd()): Promise { + // An empty value (e.g. `CODEMIE_VERSION_CHECKS_ENABLED=`) counts as unset, so it can't override + // an explicit `false` in the config. + const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED?.trim(); + if (envValue) { + return envValue !== 'false'; + } + + const scopes: Array<{ scope: string; load: () => Promise<{ workspace?: { versionChecks?: { enabled?: unknown } } }> }> = [ + { scope: 'local', load: () => ConfigLoader.loadLocalMultiProviderConfig(workingDir) }, + { scope: 'global', load: () => ConfigLoader.loadMultiProviderConfig() }, + ]; + for (const { scope, load } of scopes) { + try { + const enabled = (await load()).workspace?.versionChecks?.enabled; + if (enabled !== undefined) { + return enabled !== false; + } + } catch (error) { + logger.debug('[version-resolution] config read failed, skipping scope', { scope, error: String(error) }); + } + } + return true; +} + +// Matches a prerelease/build-metadata suffix after the numeric version, e.g. "1.2.3-beta.1" or +// "v1.2.3-rc1+build5" — npm's `latest` dist-tag should never point at one, but a live lookup is +// external input and this guards against silently presenting it as the tracked version. +const PRERELEASE_SUFFIX_PATTERN = /\d+\.\d+\.\d+[-+]/; + +export interface ResolvedSupportedVersion { + /** + * The tracked version. Only meaningful when `isCurrent` is true; otherwise it carries the + * metadata value, which callers must not install, display or compare against. + */ + version: string | undefined; + /** + * Whether `version` can be treated as the current tracked version: a successful (possibly + * cached) npm lookup for a live-tracked agent, or the maintainer-pinned value for any other + * agent. False when checks are off, or a live-tracked agent's lookup failed or was rejected — + * callers must then behave as if no supported version were configured. + */ + isCurrent: boolean; + /** + * Set only when a live-tracked agent's registry `latest` is below its hard minimum (e.g. a + * lagging mirror). The tracked version is then unknown, and installing it must stop: the + * `latest` channel would resolve to that same release the minimum gate refuses. + */ + liveBelowMinimum?: true; + /** The registry `latest` that was rejected; set together with `liveBelowMinimum`. */ + registryLatestVersion?: string; +} + +/** + * Why an install of the tracked version cannot proceed when the registry `latest` is below the + * agent's hard minimum, with the command to install an explicit version instead. + * + * @param agentName - agent metadata `name` + * @param registryLatestVersion - the registry's `latest` release + * @param minimumSupportedVersion - the agent's hard minimum + */ +export function liveBelowMinimumReason( + agentName: string, + registryLatestVersion: string, + minimumSupportedVersion: string +): string { + return ( + `the registry's latest release v${registryLatestVersion} is below the minimum supported ` + + `v${minimumSupportedVersion} (a lagging mirror?). ` + + `Install a specific version: ${getAgentInstallCommand(agentName)} ` + ); +} + +/** + * Resolve the version CodeMie tracks for an agent. Live-tracked agents use the npm `latest` + * release; a failed or rejected lookup returns the metadata fallback with `isCurrent: false`. + * Other agents keep their maintainer-pinned version, unchanged. With checks off nothing is current. + * + * @param input - agent name, npm package and metadata fallback + * @returns the resolved version and whether it is current + */ +export async function resolveSupportedVersionDetailed( + input: ResolveSupportedVersionInput +): Promise { + const { agentName, npmPackage, fallbackSupportedVersion, minimumSupportedVersion, bypassCache } = input; + const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isCurrent: false }; + + if (!(await isVersionChecksEnabled())) { + return fallback; + } + + if (!isLiveTrackedAgent(agentName) || !npmPackage) { + return { version: fallbackSupportedVersion, isCurrent: Boolean(fallbackSupportedVersion) }; + } + + try { + const live = await getCachedLatestVersion(npmPackage, { bypassCache }); + if (live && PRERELEASE_SUFFIX_PATTERN.test(live)) { + logger.debug('[resolveSupportedVersion] live version looks like a prerelease, using fallback', { + agentName, + live, + }); + return fallback; + } + const extracted = live ? extractVersion(live) : null; + // A lagging mirror or a mis-set dist-tag can report a `latest` below the hard minimum; + // tracking it would advise (and install) a version the minimum gate then refuses. + if (extracted && minimumSupportedVersion && compareVersions(extracted, minimumSupportedVersion) < 0) { + logger.debug('[resolveSupportedVersion] live version is below the minimum, using fallback', { + agentName, + live: extracted, + minimumSupportedVersion, + }); + return { ...fallback, liveBelowMinimum: true, registryLatestVersion: extracted }; + } + return extracted ? { version: extracted, isCurrent: true } : fallback; + } catch (error) { + logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); + return fallback; + } +} + +/** + * Install target for `installVersion('supported')`: the current tracked version, or the `latest` + * channel when it is unknown (checks off, lookup failed). Never a stale fallback, which can be far + * behind upstream and would install — or downgrade to — an old release. + * + * @throws {AgentInstallationError} when the registry `latest` is below the hard minimum: the + * `latest` channel would install the release the minimum gate refuses to launch. + */ +export async function resolveSupportedInstallVersion(input: ResolveSupportedVersionInput): Promise { + const { version, isCurrent, liveBelowMinimum, registryLatestVersion } = await resolveSupportedVersionDetailed(input); + if (liveBelowMinimum && registryLatestVersion && input.minimumSupportedVersion) { + throw new AgentInstallationError( + input.agentName, + liveBelowMinimumReason(input.agentName, registryLatestVersion, input.minimumSupportedVersion) + ); + } + return isCurrent && version ? version : 'latest'; +} diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts new file mode 100644 index 000000000..e911adb93 --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('../../../../utils/native-installer.js', () => ({ + installNativeAgent: vi.fn(async () => ({ success: true, installedVersion: '2.1.300', output: '' })), +})); + +// A live tracked version that differs from CLAUDE_SUPPORTED_VERSION, so a test +// passing by installing the pinned fallback is impossible. +const LIVE_VERSION = '2.1.300'; +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedInstallVersion: vi.fn(async () => LIVE_VERSION), + resolveSupportedVersionDetailed: vi.fn(async () => ({ version: LIVE_VERSION, isCurrent: true })), + isVersionChecksEnabled: vi.fn(async () => true), +})); + +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + +import { ClaudePlugin, ClaudePluginMetadata } from '../claude.plugin.js'; +import { installNativeAgent } from '../../../../utils/native-installer.js'; +import { resolveSupportedInstallVersion } from '../../../core/version-resolution.js'; +import { AgentInstallationError } from '../../../../utils/errors.js'; + +describe('ClaudePlugin.installVersion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs the live tracked version for 'supported', not the pinned fallback", async () => { + expect(ClaudePluginMetadata.supportedVersion).not.toBe(LIVE_VERSION); + + await expect(new ClaudePlugin().installVersion('supported')).resolves.toBe('2.1.300'); + + expect(resolveSupportedInstallVersion).toHaveBeenCalledWith( + expect.objectContaining({ + agentName: 'claude', + fallbackSupportedVersion: ClaudePluginMetadata.supportedVersion, + }) + ); + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + LIVE_VERSION, + expect.any(Object) + ); + }); + + it("installs the latest channel for 'supported' when the tracked version is unknown", async () => { + vi.mocked(resolveSupportedInstallVersion).mockResolvedValueOnce('latest'); + + await new ClaudePlugin().installVersion('supported'); + + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + 'latest', + expect.any(Object) + ); + }); + + it("stops without installing when 'supported' cannot be installed (registry latest below the minimum)", async () => { + const error = new AgentInstallationError('claude', 'below the minimum'); + vi.mocked(resolveSupportedInstallVersion).mockRejectedValueOnce(error); + + await expect(new ClaudePlugin().installVersion('supported')).rejects.toBe(error); + expect(installNativeAgent).not.toHaveBeenCalled(); + }); + + it('installs an explicit version as given, without resolving the tracked one', async () => { + await new ClaudePlugin().installVersion('2.1.250'); + + expect(resolveSupportedInstallVersion).not.toHaveBeenCalled(); + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + '2.1.250', + expect.any(Object) + ); + }); +}); + +describe('ClaudePlugin.getVersion', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('runs the PATH fallback through a shell on Windows, where an npm install is a .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: '2.1.284 (Claude Code)', stderr: '' }); + + await expect(new ClaudePlugin().getVersion()).resolves.toBe('2.1.284'); + expect(execMock).toHaveBeenCalledWith('claude', ['--version'], expect.objectContaining({ shell: true })); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index eb412523b..37795ae8a 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -4,6 +4,7 @@ import type { ResumeOwnershipResult, } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import { resolveSupportedInstallVersion } from '../../core/version-resolution.js'; import { ClaudeSessionAdapter } from './claude.session.js'; import { resolveClaudeModel, listRouterModelIds, buildModelLabelMap, buildModelPickerOptions, type ClaudeModelTier } from './claude.models.js'; import { writeConfigToTempFile } from '../../core/temp-config.js'; @@ -32,22 +33,17 @@ import { let statuslineManagedThisSession = false; /** - * Recommended Claude Code version — the one CodeMie verifies against. - * A different installed version produces one non-blocking notice, never a block. - * - * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** + * Marks Claude Code as version-checked. The tracked version is resolved live + * from npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ export const CLAUDE_SUPPORTED_VERSION = '2.1.281'; /** * Minimum supported Claude Code version — the only hard gate; below it the - * agent refuses to launch. - * - * Rule: the previously recommended version. When bumping - * CLAUDE_SUPPORTED_VERSION, move its old value down to here — users stay - * supported for one full recommendation cycle before they are cut off. - * - * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** + * agent refuses to launch. Maintained by hand: raise it when an older Claude + * Code version stops working with CodeMie. */ const CLAUDE_MINIMUM_SUPPORTED_VERSION = '2.1.269'; @@ -128,7 +124,7 @@ export const ClaudePluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CLAUDE_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: CLAUDE_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: CLAUDE_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run // Native installer URLs (used by installNativeAgent utility) @@ -670,9 +666,12 @@ export class ClaudePlugin extends BaseAgentAdapter { return versionMatch ? versionMatch[1] : fullPathOutput; } - // Fall back to command in PATH (works for npm installations, Windows, etc.) + // Fall back to command in PATH (works for npm installations, Windows, etc.). On Windows an + // npm install is a .cmd shim, which spawn() can only run through a shell. try { - const result = await exec(this.metadata.cliCommand, ['--version']); + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); // Parse version from output like '2.1.23 (Claude Code)' const versionMatch = result.stdout.trim().match(/^(\d+\.\d+\.\d+)/); @@ -747,16 +746,15 @@ export class ClaudePlugin extends BaseAgentAdapter { async installVersion(version?: string): Promise { const metadata = this.metadata; - // Resolve 'supported' to actual version from metadata + // Resolve 'supported' to the live tracked version ('latest' when unknown) let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!metadata.supportedVersion) { - throw new AgentInstallationError( - metadata.name, - 'No supported version defined in metadata', - ); - } - resolvedVersion = metadata.supportedVersion; + resolvedVersion = await resolveSupportedInstallVersion({ + agentName: metadata.name, + npmPackage: metadata.npmPackage, + fallbackSupportedVersion: metadata.supportedVersion, + minimumSupportedVersion: metadata.minimumSupportedVersion, + }); logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index a31c70732..95c497d2a 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../providers/core/registry.js', () => ({ ProviderRegistry: { @@ -34,9 +34,49 @@ vi.mock('../../../../utils/logger.js', () => ({ }, })); +// Codex is a live-tracked agent (LIVE_TRACKED_AGENT_NAMES), so +// checkVersionCompatibility()/installVersion() resolve `supportedVersion` +// through version-resolution, which hits the npm registry for @openai/codex's +// current `latest` tag. Without this mock, the tests below made a real +// network call and asserted against whatever version npm actually returns, +// so they failed nondeterministically in CI once a newer Codex version +// shipped. The mock echoes back fallbackSupportedVersion (reported as a +// confirmed live value) to pin the tests to CODEX_SUPPORTED_VERSION again, +// matching kimi.plugin.test.ts's pattern. +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedInstallVersion: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), + resolveSupportedVersionDetailed: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => ({ + version: fallbackSupportedVersion, + isCurrent: true, + })), +})); + +// Keep beforeRun's default CODEX_HOME out of the real user home. +const homeState = vi.hoisted(() => ({ dir: '' })); +vi.mock('../../../../utils/paths.js', async () => { + const actual = await vi.importActual( + '../../../../utils/paths.js' + ); + const { join } = await import('path'); + return { ...actual, resolveHomeDir: (p: string) => join(homeState.dir, p) }; +}); + describe('CodexPlugin version support', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + const { mkdtemp } = await import('fs/promises'); + const { tmpdir } = await import('os'); + const { join } = await import('path'); + homeState.dir = await mkdtemp(join(tmpdir(), 'codemie-codex-home-')); + }); + + afterEach(async () => { + const { rm } = await import('fs/promises'); + await rm(homeState.dir, { recursive: true, force: true }); }); it('declares the supported and minimum supported Codex CLI versions', async () => { @@ -67,6 +107,66 @@ describe('CodexPlugin version support', () => { expect(compat.compatible).toBe(false); }); + it('compares against the live tracked version when it differs from the pinned fallback', async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.160.0', + isCurrent: true, + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1\n', stderr: '' }); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + // Against the 0.154.0 fallback this install would read as "newer"; against live it is behind. + expect(compat.supportedVersion).toBe('0.160.0'); + expect(compat.versionKnown).toBe(true); + expect(compat.hasUpdate).toBe(true); + expect(compat.isNewer).toBe(false); + }); + + it('reports the tracked version as unknown, not the fallback, when resolution is not live', async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.154.0', + isCurrent: false, + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.150.0\n', stderr: '' }); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + expect(compat.versionKnown).toBe(false); + expect(compat.supportedVersion).toBe('latest'); + expect(compat.hasUpdate).toBe(false); + expect(compat.isBelowMinimum).toBe(false); + }); + + it.each([ + ['installed', { code: 0, stdout: 'codex-cli 0.150.0\n', stderr: '' }], + ['not installed', { code: 1, stdout: '', stderr: 'not found' }], + ])('surfaces a registry latest below the minimum when %s', async (_label, execResult) => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.154.0', + isCurrent: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue(execResult); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + expect(compat.versionKnown).toBe(false); + expect(compat.supportedVersion).toBe('latest'); + expect(compat.liveBelowMinimum).toBe(true); + expect(compat.registryLatestVersion).toBe('0.140.0'); + }); + it('marks Codex versions below the minimum supported version as below minimum', async () => { const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ @@ -136,6 +236,18 @@ describe('CodexPlugin version support', () => { ); }); + it("installs the live tracked version for 'supported', not the pinned fallback", async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedInstallVersion).mockResolvedValueOnce('0.160.0'); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.installGlobal).mockResolvedValue(undefined); + + const { CodexPlugin } = await import('../codex.plugin.js'); + await new CodexPlugin().installVersion('supported'); + + expect(processes.installGlobal).toHaveBeenCalledWith('@openai/codex', { version: '0.160.0' }); + }); + it('sets an isolated CODEX_HOME for CodeMie-managed Codex runs', async () => { const { CodexPluginMetadata } = await import('../codex.plugin.js'); @@ -150,17 +262,38 @@ describe('CodexPlugin version support', () => { expect(env.CODEX_HOME).toMatch(/[/\\]\.codex[/\\]codemie[/\\]home$/); }); + it('runs getVersion through a shell only on Windows, where codex is an npm .cmd shim', async () => { + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1', stderr: '' }); + const { CodexPlugin } = await import('../codex.plugin.js'); + const originalPlatform = process.platform; + + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await new CodexPlugin().getVersion(); + expect(processes.exec).toHaveBeenLastCalledWith('codex', ['--version'], expect.objectContaining({ shell: true })); + + Object.defineProperty(process, 'platform', { value: 'linux' }); + await new CodexPlugin().getVersion(); + expect(processes.exec).toHaveBeenLastCalledWith('codex', ['--version'], expect.objectContaining({ shell: false })); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('preserves an explicit CODEX_HOME override', async () => { + const { join } = await import('path'); const { CodexPluginMetadata } = await import('../codex.plugin.js'); + const customHome = join(homeState.dir, 'custom-codex-home'); const env = await CodexPluginMetadata.lifecycle!.beforeRun!( - { CODEX_HOME: '/tmp/custom-codex-home' }, + { CODEX_HOME: customHome }, { provider: 'ai-run-sso', model: 'gpt-5.5-2026-04-24', } ); - expect(env.CODEX_HOME).toBe('/tmp/custom-codex-home'); + expect(env.CODEX_HOME).toBe(customHome); }); }); diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index 7c15d8912..365e8ff5e 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -65,21 +65,17 @@ import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation import { mkdir } from 'fs/promises'; /** - * Supported Codex CLI version - * Latest version tested and verified with CodeMie backend - * - * **UPDATE THIS WHEN BUMPING CODEX VERSION** + * Marks Codex CLI as version-checked. The tracked version is resolved live from + * npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ const CODEX_SUPPORTED_VERSION = '0.154.0'; /** * Minimum supported Codex CLI version — the only hard gate; below it the agent - * refuses to launch. - * - * Rule: the previously recommended version. When bumping - * CODEX_SUPPORTED_VERSION, move its old value down to here. - * - * **UPDATE THIS WHEN BUMPING CODEX VERSION** + * refuses to launch. Maintained by hand: raise it when an older Codex CLI + * version stops working with CodeMie. */ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; @@ -113,7 +109,7 @@ export const CodexPluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CODEX_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: CODEX_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run dataPaths: { @@ -474,7 +470,11 @@ export class CodexPlugin extends BaseAgentAdapter { } try { - const result = await exec(this.metadata.cliCommand, ['--version']); + // On Windows, codex resolves to an npm .cmd shim — spawn() can only run it + // through a shell, the same reason installGlobal/uninstallGlobal set this. + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); const output = result.stdout.trim(); const versionMatch = output.match(/(\d+\.\d+\.\d+)/); return versionMatch ? versionMatch[1] : output; diff --git a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts new file mode 100644 index 000000000..0c54b4b24 --- /dev/null +++ b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../providers/core/registry.js', () => ({ + ProviderRegistry: { + registerProvider: vi.fn((template: unknown) => template), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + getProvider: vi.fn(), + getProviderNames: vi.fn(() => []), + }, +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + +import { GeminiPlugin } from '../gemini.plugin.js'; + +describe('GeminiPlugin', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + describe('getVersion', () => { + it('runs through a shell on Windows, where gemini is an npm .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: '0.59.0\n', stderr: '' }); + + await expect(new GeminiPlugin().getVersion()).resolves.toBe('0.59.0'); + expect(execMock).toHaveBeenCalledWith('gemini', ['--version'], expect.objectContaining({ shell: true })); + }); + + it('does not use a shell on other platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + execMock.mockResolvedValue({ code: 0, stdout: '0.59.0', stderr: '' }); + + await new GeminiPlugin().getVersion(); + expect(execMock).toHaveBeenCalledWith('gemini', ['--version'], expect.objectContaining({ shell: false })); + }); + }); +}); diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index c4e7ffaef..40ecfc8b9 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -8,21 +8,17 @@ import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionI import { validateGeminiModel } from './gemini.models.js'; /** - * Supported Gemini CLI version - * Latest version tested and verified with CodeMie backend - * - * **UPDATE THIS WHEN BUMPING GEMINI VERSION** + * Marks Gemini CLI as version-checked. The tracked version is resolved live + * from npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ const GEMINI_SUPPORTED_VERSION = '0.59.0'; /** * Minimum supported Gemini CLI version — the only hard gate; below it the agent - * refuses to launch. - * - * Rule: the previously recommended version. When bumping - * GEMINI_SUPPORTED_VERSION, move its old value down to here. - * - * **UPDATE THIS WHEN BUMPING GEMINI VERSION** + * refuses to launch. Maintained by hand: raise it when an older Gemini CLI + * version stops working with CodeMie. */ const GEMINI_MINIMUM_SUPPORTED_VERSION = '0.29.5'; @@ -36,7 +32,7 @@ const metadata = { cliCommand: 'gemini', // Version management configuration - supportedVersion: GEMINI_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: GEMINI_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: GEMINI_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run envMapping: { @@ -236,7 +232,11 @@ export class GeminiPlugin extends BaseAgentAdapter { try { const { exec } = await import('../../../utils/processes.js'); - const result = await exec(this.metadata.cliCommand, ['--version']); + // On Windows, gemini resolves to an npm .cmd shim — spawn() can only run it + // through a shell, the same reason installGlobal/uninstallGlobal set this. + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); // Parse semver from output (handles both '0.29.5' and '0.29.5 (Gemini CLI)' formats) const versionMatch = result.stdout.trim().match(/^(\d+\.\d+\.\d+)/); diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts index 1ec297eeb..5fed3130f 100644 --- a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts @@ -14,6 +14,12 @@ vi.mock('../../../../utils/native-installer.js', () => ({ }), })); +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedInstallVersion: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), +})); + describe('KimiPlugin', () => { beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +63,33 @@ describe('KimiPlugin', () => { ); }); + it('stops without installing when the tracked version cannot be installed (registry latest below the minimum)', async () => { + const { resolveSupportedInstallVersion } = await import('../../../core/version-resolution.js'); + const error = new AgentInstallationError('kimi', 'below the minimum'); + vi.mocked(resolveSupportedInstallVersion).mockRejectedValueOnce(error); + + await expect(new KimiPlugin().installVersion('supported')).rejects.toBe(error); + + const { installNativeAgent } = await import('../../../../utils/native-installer.js'); + expect(installNativeAgent).not.toHaveBeenCalled(); + }); + + it('installs the latest build when the tracked version is unknown', async () => { + const { resolveSupportedInstallVersion } = await import('../../../core/version-resolution.js'); + vi.mocked(resolveSupportedInstallVersion).mockResolvedValueOnce('latest'); + const plugin = new KimiPlugin(); + + await plugin.installVersion('supported'); + + const { installNativeAgent } = await import('../../../../utils/native-installer.js'); + expect(installNativeAgent).toHaveBeenCalledWith( + 'kimi', + KimiPluginMetadata.installerUrls, + undefined, + expect.any(Object), + ); + }); + it('installs npm version natively', async () => { const plugin = new KimiPlugin(); diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts new file mode 100644 index 000000000..500bfec39 --- /dev/null +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + +import { KimiPlugin } from '../kimi.plugin.js'; + +describe('KimiPlugin.getVersion on Windows', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('runs the PATH fallback through a shell, where an npm install is a .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: 'kimi, version 2.1.1\n', stderr: '' }); + + await expect(new KimiPlugin().getVersion()).resolves.toBe('2.1.1'); + expect(execMock).toHaveBeenCalledWith('kimi', ['--version'], expect.objectContaining({ shell: true })); + }); +}); diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index ec225310b..026255458 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -2,6 +2,7 @@ import type { AgentConfig, AgentMetadata, HookTransformer } from '../../core/typ import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; +import { resolveSupportedInstallVersion } from '../../core/version-resolution.js'; import { existsSync } from 'fs'; import { rm } from 'fs/promises'; import { KimiSessionAdapter } from './kimi.session.js'; @@ -20,9 +21,11 @@ import { sanitizeLogArgs } from '../../../utils/security.js'; import { commandExists, exec, getCommandPath } from '../../../utils/processes.js'; import { resolveHomeDir } from '../../../utils/paths.js'; -// Recommended version (one non-blocking notice on mismatch) and the hard gate -// below which the agent refuses to launch. Rule: the minimum is the previously -// recommended version — when bumping the former, move its old value to the latter. +// KIMI_SUPPORTED_VERSION only marks Kimi as version-checked: the tracked version +// is resolved live from npm (see `LIVE_TRACKED_AGENT_NAMES`) and this value is +// never presented as current, so it needs no bumping. The minimum is the hard +// gate below which the agent refuses to launch; maintained by hand — raise it +// when an older Kimi version stops working with CodeMie. const KIMI_SUPPORTED_VERSION = '0.42.0'; const KIMI_MINIMUM_SUPPORTED_VERSION = '0.16.0'; const KIMI_NATIVE_BINARY_PATH = '.kimi-code/bin/kimi'; @@ -324,9 +327,12 @@ export class KimiPlugin extends BaseAgentAdapter { } } - // Fall back to command in PATH + // Fall back to command in PATH. On Windows an npm install is a .cmd shim, which spawn() can + // only run through a shell (same as Codex and Gemini). try { - const result = await exec(this.metadata.cliCommand, ['--version']); + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); return parseVersion(result.stdout); } catch { return null; @@ -334,16 +340,17 @@ export class KimiPlugin extends BaseAgentAdapter { } override async installVersion(version?: string): Promise { - // Resolve 'supported' to the version from metadata + // Resolve 'supported' to the live tracked version. When that's unknown it + // resolves to the 'latest' channel, which the native installer takes as undefined. let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!this.metadata.supportedVersion) { - throw new AgentInstallationError( - this.metadata.name, - 'No supported version defined in metadata', - ); - } - resolvedVersion = this.metadata.supportedVersion; + const resolved = await resolveSupportedInstallVersion({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, + }); + resolvedVersion = resolved === 'latest' ? undefined : resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index d75f7803d..7fb2269fd 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -53,6 +53,11 @@ vi.mock('@/utils/processes.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getLatestVersion: npmMock.getLatestVersion, installGlobal: npmMock.installGlobal }; }); +// Live-tracked agents read the registry directly; route it to the same mock. +vi.mock('@/utils/npm-registry.js', () => ({ + fetchLatestVersionFromRegistry: (pkg: string) => npmMock.getLatestVersion(pkg), + resolveRegistry: () => 'https://registry.npmjs.org/', +})); // restoreCliBinLink — no-op (would otherwise touch the filesystem). vi.mock('@/utils/cli-bin.js', () => ({ restoreCliBinLink: vi.fn(async () => {}) })); @@ -274,6 +279,149 @@ describe('createListCommand', () => { // createUpdateCommand — spawn is mocked; we only assert the install args. // =========================================================================== describe('createUpdateCommand', () => { + // The env var wins over every config scope, so these tests never depend on + // the developer's own versionChecks setting. + beforeEach(() => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'true'; + }); + afterEach(() => { + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; + }); + + it('skips a live-tracked agent with a note, and never looks it up, when version checks are disabled', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + const agent = { + name: 'gemini', + displayName: 'Gemini CLI', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: '@google/gemini-cli' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }; + registryMock.getAgent.mockReturnValue(agent as never); + + const cmd = createUpdateCommand(); + await cmd.parseAsync(['gemini'], { from: 'user' }); + + expect(captured()).toContain('Version checks are disabled'); + expect(captured()).not.toContain('Could not check'); + expect(spinner.warn).not.toHaveBeenCalled(); + expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + + it('explains an empty result instead of "No updatable agents installed" when checks are disabled', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + registryMock.getManageableAgents.mockReturnValue([ + { + name: 'gemini', + displayName: 'Gemini CLI', + metadata: { isBuiltIn: false, npmPackage: '@google/gemini-cli' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }, + ] as never); + + const cmd = createUpdateCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('version checks are disabled')); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + }); + + // Each test below uses its own package name so earlier tests' cache entries can't satisfy it. + function liveTrackedAgent(npmPackage: string, installed = '1.0.0'): Record { + return { + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'd', + metadata: { isBuiltIn: false, npmPackage, supportedVersion: '9.9.9' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => installed), + installVersion: vi.fn(async () => '9.9.9'), + }; + } + + it('reports an installed agent whose lookup failed instead of "No updatable agents installed"', async () => { + registryMock.getManageableAgents.mockReturnValue([liveTrackedAgent('@codemie-test/all-offline')] as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + await createUpdateCommand().parseAsync([], { from: 'user' }); + + expect(captured()).toContain('Could not check OpenAI Codex CLI for updates'); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + }); + + it('reports the built-in agent whose CLI lookup failed instead of "No updatable agents installed"', async () => { + registryMock.getManageableAgents.mockReturnValue([ + { + name: 'codemie-code', + displayName: 'CodeMie Code', + description: 'd', + metadata: { isBuiltIn: true, npmPackage: null }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }, + ] as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + await createUpdateCommand().parseAsync([], { from: 'user' }); + + expect(captured()).toContain('Could not check CodeMie Code for updates'); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + }); + + it('lists the agents it could check and reports the one whose lookup failed', async () => { + const opencode = { + name: 'opencode', + displayName: 'OpenCode', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: 'opencode-ai' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }; + registryMock.getManageableAgents.mockReturnValue([ + opencode, + liveTrackedAgent('@codemie-test/mixed-offline'), + ] as never); + npmMock.getLatestVersion.mockImplementation(async (pkg: string) => (pkg === 'opencode-ai' ? '2.0.0' : null)); + + await createUpdateCommand().parseAsync(['--check'], { from: 'user' }); + + const output = captured(); + expect(output).toContain('OpenCode'); + expect(output).toContain('2.0.0'); + expect(output).toContain('Could not check OpenAI Codex CLI for updates'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + + it('never offers the hardcoded fallback as an update when the live lookup fails', async () => { + const agent = liveTrackedAgent('@codemie-test/lookup-fails'); + registryMock.getAgent.mockReturnValue(agent as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + const cmd = createUpdateCommand(); + await cmd.parseAsync(['codex'], { from: 'user' }); + + expect(spinner.warn).toHaveBeenCalledWith('Could not check OpenAI Codex CLI for updates'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + }); + + it('queries the registry on every explicit check, bypassing a fresh cache entry', async () => { + registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/cached') as never); + npmMock.getLatestVersion.mockResolvedValue('2.0.0'); + await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); + + // A release published after the first check must be seen right away, not after 24h. + npmMock.getLatestVersion.mockResolvedValue('3.0.0'); + await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); + + expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(2); + expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('3.0.0')); + }); + it('updates a specific npm-based agent via installGlobal with force:true', async () => { const agent = { name: 'gemini', @@ -297,6 +445,27 @@ describe('createUpdateCommand', () => { }); }); + it('updates Claude to the exact version it offered, through its own installer, not npm', async () => { + const agent = { + name: 'claude', + displayName: 'Claude Code', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: '@codemie-test/claude-update', supportedVersion: '1.0.0' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.5.0 (Claude Code)'), + installVersion: vi.fn(async () => '2.0.0'), + warnOnceIfUntested: vi.fn(async () => undefined), + }; + registryMock.getAgent.mockReturnValue(agent as never); + npmMock.getLatestVersion.mockResolvedValue('2.0.0'); + + await createUpdateCommand().parseAsync(['claude'], { from: 'user' }); + + // The version the check displayed — not 'supported' re-resolved through the cache. + expect(agent.installVersion).toHaveBeenCalledWith('2.0.0'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + it('does NOT install in --check mode', async () => { const agent = { name: 'gemini', diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index 2bb9345a4..220c1015f 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const getAgentMock = vi.fn(); const restoreCliBinLinkMock = vi.fn(); @@ -24,6 +24,9 @@ vi.mock('../../../utils/logger.js', () => ({ }, })); +const promptMock = vi.hoisted(() => vi.fn()); +vi.mock('inquirer', () => ({ default: { prompt: promptMock } })); + vi.mock('ora', () => ({ default: vi.fn(() => ({ start: vi.fn(() => ({ @@ -71,13 +74,231 @@ describe('install command version selection', () => { await command.parseAsync(['node', 'codemie', 'codex']); expect(checkVersionCompatibility).toHaveBeenCalled(); - expect(installVersion).toHaveBeenCalledWith('supported'); + // The exact tracked version that was shown, not 'supported' re-resolved. + expect(installVersion).toHaveBeenCalledWith('0.129.0'); expect(restoreCliBinLinkMock).toHaveBeenCalledOnce(); expect(spinnerSucceedMock).toHaveBeenCalledWith( 'OpenAI Codex CLI v0.129.0 installed successfully' ); }); + function codexWithUnknownTrackedVersion(installed: boolean, installVersion = vi.fn().mockResolvedValue('0.170.0')) { + return { + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'OpenAI Codex CLI - AI coding agent by OpenAI', + metadata: {}, + isInstalled: vi.fn().mockResolvedValue(installed), + install: vi.fn().mockResolvedValue(undefined), + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: installed ? '0.150.0' : null, + compatible: true, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue(installed ? '0.150.0' : '0.170.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }; + } + + it('--supported asks before reinstalling the latest release when the tracked version is unknown', async () => { + const agent = codexWithUnknownTrackedVersion(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: true }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(promptMock).toHaveBeenCalledWith([ + expect.objectContaining({ message: 'Reinstall with the latest release?', default: false }), + ]); + expect(agent.installVersion).toHaveBeenCalledWith('supported'); + }); + + it('--supported leaves the installed agent alone when the reinstall is declined', async () => { + const agent = codexWithUnknownTrackedVersion(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: false }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(agent.installVersion).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('Installation cancelled'); + }); + + it('--supported installs the latest release without asking when the agent is not installed', async () => { + const agent = codexWithUnknownTrackedVersion(false); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(promptMock).not.toHaveBeenCalled(); + expect(agent.installVersion).toHaveBeenCalledWith('supported'); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('Tracked version unavailable'); + }); + + it('a plain install of an installed agent stays a no-op when the tracked version is unknown', async () => { + const installVersion = vi.fn(); + const install = vi.fn(); + + getAgentMock.mockReturnValue({ + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'OpenAI Codex CLI - AI coding agent by OpenAI', + metadata: {}, + isInstalled: vi.fn().mockResolvedValue(true), + install, + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: '0.150.0', + compatible: true, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue('0.150.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }); + + const { createInstallCommand } = await import('../install.js'); + const command = createInstallCommand(); + + await command.parseAsync(['node', 'codemie', 'codex']); + + expect(installVersion).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('is already installed'); + }); + + function codexWithLaggingRegistry(installed: boolean) { + return { + ...codexWithUnknownTrackedVersion(installed), + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: installed ? '0.150.0' : null, + compatible: installed, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + minimumSupportedVersion: '0.143.0', + versionKnown: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }), + }; + } + + describe('when the registry latest is below the minimum', () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function expectStoppedWithBelowMinimumError(agent: ReturnType): void { + expect(agent.install).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + const errors = errorSpy.mock.calls.flat().join('\n'); + expect(errors).toContain( + "OpenAI Codex CLI: the registry's latest release v0.140.0 is below the minimum supported v0.143.0" + ); + expect(errors).toContain('codemie install codex '); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).not.toContain('Tracked version unavailable'); + } + + it('a plain install stops instead of installing the lagging latest release', async () => { + const agent = codexWithLaggingRegistry(false); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex']); + + expectStoppedWithBelowMinimumError(agent); + }); + + it('a plain install of an already installed agent stays a no-op instead of erroring', async () => { + const agent = codexWithLaggingRegistry(true); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex']); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(agent.install).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('is already installed'); + }); + + it('--supported stops without offering a reinstall of the latest release', async () => { + const agent = codexWithLaggingRegistry(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: true }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expectStoppedWithBelowMinimumError(agent); + }); + }); + + it('--supported on an agent with no tracked version says so and installs the latest release', async () => { + const installVersion = vi.fn().mockResolvedValue('1.2.0'); + getAgentMock.mockReturnValue({ + name: 'opencode', + displayName: 'OpenCode', + description: 'OpenCode', + metadata: { name: 'opencode', npmPackage: 'opencode-ai' }, + isInstalled: vi.fn().mockResolvedValue(false), + install: vi.fn().mockResolvedValue(undefined), + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: null, + compatible: false, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue('1.2.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'opencode', '--supported']); + + expect(installVersion).toHaveBeenCalledWith('supported'); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('OpenCode has no tracked version'); + expect(printed).toContain('installing the latest release'); + expect(printed).not.toContain('version checks disabled'); + expect(printed).not.toContain('npm unreachable'); + }); + it('uses the version returned by installVersion() for the success message', async () => { const installVersion = vi.fn().mockResolvedValue('2.1.34'); const getVersion = vi.fn().mockResolvedValue('2.1.33'); // stale — must NOT appear in spinner @@ -108,7 +329,7 @@ describe('install command version selection', () => { await command.parseAsync(['node', 'codemie', 'claude']); - expect(installVersion).toHaveBeenCalledWith('supported'); + expect(installVersion).toHaveBeenCalledWith('2.1.34'); // must show the version from installVersion(), not the stale '2.1.33' from getVersion() expect(spinnerSucceedMock).toHaveBeenCalledWith('Claude Code v2.1.34 installed successfully'); }); diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index 519283a9f..a466d7c79 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -2,13 +2,14 @@ * Installed agents health check * * Reports each installed agent's version against the version CodeMie - * recommends: a match is `ok`, a mismatch is a `warn` carrying the - * recommendation, and a version below the minimum supported one is an `error` + * is tracking: a match is `ok`, a mismatch is a `warn` naming the + * tracked version, and a version below the minimum supported one is an `error` * (that is the only version state that actually blocks the agent). */ import { AgentRegistry } from '../../../../agents/registry.js'; import { AgentAdapter } from '../../../../agents/core/types.js'; +import { isAheadOfLiveTracking } from '../../../../agents/core/version-resolution.js'; import { ItemWiseHealthCheck, HealthCheckResult, HealthCheckDetail } from '../types.js'; export class AgentsCheck implements ItemWiseHealthCheck { @@ -57,10 +58,14 @@ export class AgentsCheck implements ItemWiseHealthCheck { }; } - if (version !== compat.supportedVersion) { + if ( + compat.versionKnown !== false && + !isAheadOfLiveTracking(agent.name, compat) && + version !== compat.supportedVersion + ) { return { status: 'warn', - message: `${agent.displayName}${versionStr} - CodeMie recommends v${compat.supportedVersion}`, + message: `${agent.displayName}${versionStr} - CodeMie is tracking v${compat.supportedVersion}`, hint: `codemie install ${agent.name} --supported` }; } diff --git a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts index 26701e108..978fef7ca 100644 --- a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts +++ b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts @@ -295,6 +295,84 @@ describe('AgentsCheck', () => { ]); }); + it('warns when the installed version differs from a known tracked version', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '2.0.0', supportedVersion: '2.1.0', + isNewer: false, hasUpdate: true, isBelowMinimum: false, versionKnown: true, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0]).toMatchObject({ status: 'warn' }); + expect(result.details[0].message).toContain('tracking v2.1.0'); + }); + + it.each([ + ['claude', 'Claude Code', 'ok'], + ['copilot-cli', 'Copilot CLI', 'warn'], + ])('reports %s installed ahead of its tracked version as %s (no downgrade hint when live-tracked)', async ( + name, + displayName, + status + ) => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name, + displayName, + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.2.0', + checkVersionCompatibility: async () => ({ + compatible: false, installedVersion: '2.2.0', supportedVersion: '2.1.0', + isNewer: true, hasUpdate: false, isBelowMinimum: false, versionKnown: true, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0]).toMatchObject({ status }); + }); + + it('stays ok, never "tracking vlatest", when the tracked version is unknown', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '2.0.0', supportedVersion: 'latest', + isNewer: false, hasUpdate: false, isBelowMinimum: false, versionKnown: false, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details).toEqual([{ status: 'ok', message: 'Claude Code (2.0.0)' }]); + }); + + it('still reports a below-minimum version when the tracked version is unknown', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '1.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '1.0.0', supportedVersion: 'latest', + isNewer: false, hasUpdate: false, isBelowMinimum: true, + minimumSupportedVersion: '2.0.0', versionKnown: false, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0].status).toBe('error'); + expect(result.details[0].message).toContain('below minimum supported v2.0.0'); + }); + it('warns for agents installed via the deprecated npm method', async () => { h.getInstalledAgentsMock.mockResolvedValue([ { diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 2e4602634..b78f9fda8 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -32,7 +32,7 @@ export function createDoctorCommand(): Command { command .description('Check system health and configuration') .option('-v, --verbose', 'Enable verbose debug output with detailed API logs') - .option('--reset-version-warnings', 'Show agent version recommendations again on next launch') + .option('--reset-version-warnings', 'Show agent version notices again on next launch') .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean }) => { if (options.resetVersionWarnings) { const { removed } = await VersionWarningStore.clear(); diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 57df24ff2..6902176b6 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -4,7 +4,8 @@ import { getAgentInstallCommand, getAgentLauncherCommand, getUserFacingAgentName import { AgentInstallationError, getErrorMessage } from '@/utils/errors.js'; import { logger } from '@/utils/logger.js'; import { restoreCliBinLink } from '@/utils/cli-bin.js'; -import type { AgentInstallationOptions } from '@/agents/core/types.js'; +import type { AgentAdapter, AgentInstallationOptions, VersionCompatibilityResult } from '@/agents/core/types.js'; +import { isLiveTrackedAgent, liveBelowMinimumReason } from '@/agents/core/version-resolution.js'; import { STATUSLINE_NAME, STATUSLINE_DISPLAY_NAME, @@ -22,7 +23,7 @@ export function createInstallCommand(): Command { .description('Install an external AI coding agent or development framework') .argument('[name]', 'Agent or framework name to install (run without argument to see available)') .argument('[version]', 'Optional: specific version to install (e.g., 2.0.30)') - .option('--supported', 'Install the latest supported version tested with CodeMie') + .option('--supported', 'Install the version CodeMie is currently tracking') .option('--verbose', 'Show detailed installation logs for troubleshooting') .option('--sounds', 'Enable sounds (plays audio on hook events)') .action(async (name?: string, version?: string, options?: AgentInstallationOptions & { supported?: boolean }) => { @@ -105,6 +106,9 @@ export function createInstallCommand(): Command { // Determine which version to install let versionToInstall: string | undefined; let actualVersionToInstall: string | undefined; // Resolved version for display + let trackedVersionUnknown = false; + // Neither pinned nor live-tracked (e.g. opencode, pi): --supported has no tracked version to install. + const hasNoTrackedVersion = !agent.metadata?.supportedVersion && !isLiveTrackedAgent(agent.name); // Priority: --supported flag > version argument > 'supported' (default for Claude) > undefined (latest) if (options?.supported) { @@ -112,18 +116,41 @@ export function createInstallCommand(): Command { // Resolve 'supported' to actual version for display and comparison if (agent.checkVersionCompatibility) { const compat = await agent.checkVersionCompatibility(); - actualVersionToInstall = compat.supportedVersion; + if (compat.liveBelowMinimum) { + exitBelowMinimum(agent, compat); + return; + } + if (compat.versionKnown === false) { + // installVersion('supported') then installs the latest release, not the stale fallback + trackedVersionUnknown = true; + } else { + actualVersionToInstall = compat.supportedVersion; + } } } else if (version) { versionToInstall = version; actualVersionToInstall = version; } else if ((agent.name === 'claude' || agent.name === 'codex') && agent.checkVersionCompatibility) { - // Default to supported version for agents whose backend compatibility is version-sensitive - versionToInstall = 'supported'; + // Default to supported version for agents whose backend compatibility is version-sensitive; + // with the tracked version unknown this stays a plain install of the latest release. const compat = await agent.checkVersionCompatibility(); - actualVersionToInstall = compat.supportedVersion; + if (compat.liveBelowMinimum) { + // The latest release is the one the minimum gate refuses to launch. Only stop + // when an install would happen; an installed agent stays the usual no-op below. + if (!(await agent.isInstalled())) { + exitBelowMinimum(agent, compat); + return; + } + } else if (compat.versionKnown !== false) { + versionToInstall = 'supported'; + actualVersionToInstall = compat.supportedVersion; + } } + const unknownTrackedReason = hasNoTrackedVersion + ? `${agent.displayName} has no tracked version` + : 'the tracked version is unavailable (version checks disabled or npm unreachable)'; + // Check if already installed with matching version if (await agent.isInstalled()) { const installedVersion = await agent.getVersion(); @@ -141,7 +168,7 @@ export function createInstallCommand(): Command { return; } else { // Different version installed, ask to reinstall - const versionDisplay = options?.supported ? `${actualVersionToInstall} (supported)` : actualVersionToInstall; + const versionDisplay = options?.supported ? `${actualVersionToInstall} (tracked)` : actualVersionToInstall; console.log(chalk.yellow(`${agent.displayName} v${installedVersion} is already installed (requested: ${versionDisplay})`)); const inquirer = (await import('inquirer')).default; const { confirm } = await inquirer.prompt([ @@ -158,7 +185,7 @@ export function createInstallCommand(): Command { return; } } - } else if (!actualVersionToInstall) { + } else if (!versionToInstall) { // No specific version requested, already installed console.log(chalk.blueBright(`${agent.displayName} is already installed`)); @@ -168,24 +195,60 @@ export function createInstallCommand(): Command { } return; + } else if (trackedVersionUnknown) { + // --supported with no known target: ask, as for any other version change + const installedDisplay = installedVersion ? ` v${installedVersion}` : ''; + console.log( + chalk.yellow( + `${agent.displayName}${installedDisplay} is already installed; ${unknownTrackedReason}.` + ) + ); + const inquirer = (await import('inquirer')).default; + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Reinstall with the latest release?', + default: false, + }, + ]); + + if (!confirm) { + console.log(chalk.gray('Installation cancelled')); + return; + } } } // Build installation message const isUsingSupported = versionToInstall === 'supported'; const versionMessage = isUsingSupported && actualVersionToInstall - ? ` v${actualVersionToInstall} (supported version)` + ? ` v${actualVersionToInstall} (tracked version)` : actualVersionToInstall ? ` v${actualVersionToInstall}` : ''; + if (trackedVersionUnknown) { + console.log( + chalk.dim( + hasNoTrackedVersion + ? `${unknownTrackedReason} — installing the latest release.` + : 'Tracked version unavailable (version checks disabled or npm unreachable) — installing the latest release.' + ) + ); + } + const spinner = ora(`Installing ${agent.displayName}${versionMessage}...`).start(); try { // Use installVersion if available and version specified let installedVersion: string | null = null; if (versionToInstall && agent.installVersion) { - installedVersion = await agent.installVersion(versionToInstall); + // Install the tracked version shown above, not 'supported' re-resolved — a second + // lookup could return a different value than the one the user just confirmed. + const target = + versionToInstall === 'supported' && actualVersionToInstall ? actualVersionToInstall : versionToInstall; + installedVersion = await agent.installVersion(target); } else { await agent.install(); } @@ -346,3 +409,18 @@ export function createInstallCommand(): Command { return command; } + +/** + * Stop an install of the tracked version when the registry's latest release is below the agent's + * hard minimum (e.g. a lagging mirror): installing `latest` would install a release the minimum + * gate then refuses to launch. + */ +function exitBelowMinimum(agent: AgentAdapter, compat: VersionCompatibilityResult): void { + const reason = liveBelowMinimumReason( + agent.name, + compat.registryLatestVersion ?? 'unknown', + compat.minimumSupportedVersion ?? 'unknown' + ); + console.error(chalk.red(`✗ ${agent.displayName}: ${reason}`)); + process.exit(1); +} diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index eb72ac7b2..93ffd5529 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -14,6 +14,7 @@ import { import { FirstTimeExperience } from '../first-time.js'; import { AgentRegistry } from '../../agents/registry.js'; import type { VersionCompatibilityResult } from '../../agents/core/types.js'; +import { FETCH_TIMEOUT_MS } from '../../utils/version-cache.js'; import { createAssistantsSetupCommand } from './assistants/setup/index.js'; import { createSkillsSetupCommand } from './skills/setup/index.js'; @@ -699,9 +700,14 @@ export async function autoSelectModelTiers( return result; } +// The version check reads the config and then runs its own FETCH_TIMEOUT_MS-bounded registry +// lookup, so its worst case ends later than FETCH_TIMEOUT_MS; the margin keeps this outer race +// from losing to that inner timeout on a cold cache. +const CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000; + /** * Check and install Claude Code if needed - * Called during first-time setup to ensure Claude is installed with supported version + * Called during first-time setup; installs the tracked version (the latest release when unknown) */ async function checkAndInstallClaude(): Promise { try { @@ -729,10 +735,10 @@ async function checkAndInstallClaude(): Promise { ]); if (installClaude) { - const spinner = ora('Installing Claude Code (supported version)...').start(); + const spinner = ora('Installing Claude Code...').start(); try { - // Install supported version + // Installs the tracked version, or the latest release when that is unknown if (claude.installVersion) { await claude.installVersion('supported'); } else { @@ -772,21 +778,25 @@ async function checkAndInstallClaude(): Promise { const compat = await Promise.race([ claude.checkVersionCompatibility(), new Promise((_, reject) => - setTimeout(() => reject(new Error('Version check timeout')), 3000) + setTimeout(() => reject(new Error('Version check timeout')), CLAUDE_VERSION_CHECK_TIMEOUT_MS) ) ]) as VersionCompatibilityResult; - if (compat.isNewer) { - // Installed version is newer than supported - console.log(); - console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` CodeMie has only tested and verified v${compat.supportedVersion}`)); + // Below the hard minimum the launch gate refuses Claude, whatever the tracked version says. + if (compat.isBelowMinimum) { console.log(); - console.log(chalk.white(' To install the supported version:')); - console.log(chalk.blueBright(' codemie install claude --supported')); + console.log( + chalk.yellow( + `⚠ Claude Code v${compat.installedVersion} is below the minimum supported version` + + (compat.minimumSupportedVersion ? ` v${compat.minimumSupportedVersion}` : '') + + ' and will not launch' + ) + ); + console.log(chalk.yellow('Update it using:'), chalk.blueBright('codemie install claude --supported')); console.log(); - } else if (compat.compatible) { - // Version is compatible (same or older than supported) + } else if (compat.compatible || compat.isNewer) { + // Claude is live-tracked: being ahead of the tracked version usually means it + // self-updated since the cached lookup, so there is nothing to advise. console.log(); console.log(chalk.green(`✓ Claude Code v${compat.installedVersion} is installed`)); console.log(); diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index fea84684d..67f9f6997 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -7,7 +7,8 @@ import { logger } from '../../utils/logger.js'; import * as npm from '../../utils/processes.js'; import { restoreCliBinLink } from '../../utils/cli-bin.js'; import { CLI_PACKAGE_NAME } from '../../utils/cli-updater.js'; -import { compareVersions, isValidSemanticVersion } from '../../utils/version-utils.js'; +import { compareVersions, isValidSemanticVersion, extractVersion } from '../../utils/version-utils.js'; +import { isLiveTrackedAgent, isVersionChecksEnabled, resolveSupportedVersionDetailed } from '../../agents/core/version-resolution.js'; import ora from 'ora'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -30,20 +31,16 @@ interface UpdateCheckResult { npmPackage: string; } -/** - * Extract semver version from a string that may contain extra text - * e.g., "2.0.76 (Claude Code)" -> "2.0.76" - * "v1.2.3-beta" -> "1.2.3" - */ -function extractVersion(versionString: string): string | null { - const match = versionString.match(/v?(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} +// Returned when an installed agent could not be checked because its latest-version lookup +// failed (offline, registry error, timeout) — as opposed to `null`: nothing to check. +const LOOKUP_FAILED = 'lookup-failed' as const; /** * Check a single agent for available updates */ -async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAgentForUpdate( + agent: AgentAdapter +): Promise { // Check if installed const installed = await agent.isInstalled(); if (!installed) { @@ -56,29 +53,6 @@ async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAllAgentsForUpdates(): Promise<{ results: UpdateCheckResult[]; unchecked: string[] }> { const agents = AgentRegistry.getManageableAgents(); const results: UpdateCheckResult[] = []; + const unchecked: string[] = []; // Check all agents in parallel const checks = await Promise.all( - agents.map(agent => checkAgentForUpdate(agent)) + agents.map(async agent => ({ agent, result: await checkAgentForUpdate(agent) })) ); - for (const result of checks) { - if (result) { + for (const { agent, result } of checks) { + if (result === LOOKUP_FAILED) { + unchecked.push(agent.displayName); + } else if (result) { results.push(result); } } - return results; + return { results, unchecked }; } /** @@ -209,9 +205,10 @@ async function promptAgentSelection(outdated: UpdateCheckResult[]): Promise { - // Special handling for Claude (uses native installer) + // Special handling for Claude (uses native installer). Install the exact version the check + // offered rather than re-resolving 'supported', which could read a different cached value. if (agent.name === 'claude' && agent.installVersion) { - await agent.installVersion('supported'); + await agent.installVersion(latestVersion); } else if (agent.metadata.isBuiltIn) { // Special handling for built-in agent — update the CLI package await npm.installGlobal(CLI_PACKAGE_NAME, { version: latestVersion, force: true }); @@ -250,6 +247,7 @@ export function createUpdateCommand(): Command { console.log(chalk.gray('🔍 Verbose mode enabled - showing detailed logs\n')); } + const versionChecksEnabled = await isVersionChecksEnabled(); const checkOnly = options?.check ?? false; // Case 1: Update specific agent @@ -275,22 +273,32 @@ export function createUpdateCommand(): Command { return; } + if (!versionChecksEnabled && isLiveTrackedAgent(agent.name)) { + console.log( + chalk.dim( + `Version checks are disabled (versionChecks.enabled=false) — skipping the update check for ${agent.displayName}.` + ) + ); + console.log(chalk.dim(`To install the newest release anyway: codemie install ${agent.name} latest`)); + return; + } + const spinner = ora(`Checking ${agent.displayName} for updates...`).start(); const result = await checkAgentForUpdate(agent); - if (!result) { + if (!result || result === LOOKUP_FAILED) { spinner.warn(`Could not check ${agent.displayName} for updates`); return; } if (!result.hasUpdate) { - // For Claude, clarify it's the latest supported version (not absolute latest) - if (agent.name === 'claude') { - spinner.succeed(`${agent.displayName} is already up to date with latest verified version by CodeMie (${result.currentVersion})`); - } else { - spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); - } + // Live-tracked agents resolve against a cached npm lookup rather than an absolute + // "latest", so make that distinction explicit instead of a bare "up to date". + const upToDateMessage = isLiveTrackedAgent(agent.name) + ? `${agent.displayName} is already up to date — no newer version available (${result.currentVersion})` + : `${agent.displayName} is already up to date (${result.currentVersion})`; + spinner.succeed(upToDateMessage); return; } @@ -319,9 +327,30 @@ export function createUpdateCommand(): Command { } // Case 2: Check/update all agents + if (!versionChecksEnabled) { + console.log( + chalk.dim('Version checks are disabled (versionChecks.enabled=false) — live-tracked agents are skipped.\n') + ); + } const spinner = ora('Checking for updates...').start(); - const results = await checkAllAgentsForUpdates(); + const { results, unchecked } = await checkAllAgentsForUpdates(); + const reportUnchecked = (): void => { + for (const name of unchecked) { + console.log(chalk.yellow(`⚠ Could not check ${name} for updates`)); + } + }; + + if (results.length === 0 && unchecked.length > 0) { + spinner.stop(); + reportUnchecked(); + return; + } + + if (results.length === 0 && !versionChecksEnabled) { + spinner.info('Nothing to check — live-tracked agents are skipped while version checks are disabled'); + return; + } if (results.length === 0) { spinner.info('No updatable agents installed'); @@ -334,6 +363,7 @@ export function createUpdateCommand(): Command { // Display status displayUpdateStatus(results); + reportUnchecked(); // Filter to agents with updates const outdated = results.filter(r => r.hasUpdate); diff --git a/src/env/types.ts b/src/env/types.ts index e37ead7ac..3ed660a5a 100644 --- a/src/env/types.ts +++ b/src/env/types.ts @@ -141,6 +141,9 @@ export interface WorkspaceConfig { dryRun?: boolean; // Dry-run mode: log metrics without sending (default: false) }; }; + + // Live agent version check toggle — fail-safe: enabled unless explicitly set to false + versionChecks?: { enabled?: boolean }; } /** diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts new file mode 100644 index 000000000..74c391dcc --- /dev/null +++ b/src/utils/__tests__/npm-registry.test.ts @@ -0,0 +1,277 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Duplex } from 'node:stream'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fetchLatestVersionFromRegistry, resolveRegistry } from '../npm-registry.js'; + +// Real HTTP against a local server: the request path, proxying and timeouts are what matter here. +let server: Server; +let baseUrl: string; +let handler: (req: IncomingMessage, res: ServerResponse) => void; +const seenPaths: string[] = []; +// Targets of CONNECT tunnels the local server was asked to open (it acts as an https proxy). +const connectTargets: string[] = []; + +const ENV_KEYS = [ + 'npm_config_registry', + 'NPM_CONFIG_REGISTRY', + 'npm_config_userconfig', + 'NPM_CONFIG_USERCONFIG', + 'npm_config_proxy', + 'npm_config_https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', + 'CODEMIE_NO_SYSTEM_PROXY', + // Set when the test runner itself was started via npm/npx; cleared so each test decides. + 'npm_command', + 'npm_execpath', + 'npm_lifecycle_event', +]; +const savedEnv: Record = {}; +let workDir: string; + +beforeAll(async () => { + server = createServer((req, res) => { + seenPaths.push(req.url ?? ''); + handler(req, res); + }); + server.on('connect', (req: IncomingMessage, socket: Duplex) => { + connectTargets.push(req.url ?? ''); + socket.destroy(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; +}); + +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(async () => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + workDir = await mkdtemp(join(tmpdir(), 'codemie-npm-registry-')); + // No user .npmrc, so the developer's own npm settings can't leak into the tests. + process.env.npm_config_userconfig = join(workDir, 'no-user-npmrc'); + // Nor the machine's Windows proxy/PAC settings, whose registry read can also outlast the + // short timeouts below when the suite runs under full parallel load. + process.env.CODEMIE_NO_SYSTEM_PROXY = '1'; + seenPaths.length = 0; + connectTargets.length = 0; + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ name: '@openai/codex', version: '0.160.0' })); + }; +}); + +afterEach(async () => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + await rm(workDir, { recursive: true, force: true }); +}); + +const fetchFrom = (pkg: string, timeoutMs = 2000) => fetchLatestVersionFromRegistry(pkg, { timeoutMs }); + +// Writes the user-level .npmrc (the only npm config file the lookup reads). +const writeUserNpmrc = async (content: string): Promise => { + const userNpmrc = join(workDir, 'user-npmrc'); + await writeFile(userNpmrc, content, 'utf-8'); + process.env.npm_config_userconfig = userNpmrc; +}; + +describe('resolveRegistry', () => { + it('defaults to the public npm registry', () => { + expect(resolveRegistry('@openai/codex')).toBe('https://registry.npmjs.org/'); + }); + + it('prefers a scoped registry from the user .npmrc over the default registry', async () => { + await writeUserNpmrc(`registry=${baseUrl}\n@openai:registry=${baseUrl}scoped\n`); + + expect(resolveRegistry('@openai/codex')).toBe(`${baseUrl}scoped/`); + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); + }); + + it('lets the npm_config_registry env var override .npmrc', async () => { + await writeUserNpmrc('registry=https://example.invalid/\n'); + process.env.npm_config_registry = baseUrl; + + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); + }); + + it('strips quotes around .npmrc values', async () => { + await writeUserNpmrc(`registry="${baseUrl}quoted"\n`); + + expect(resolveRegistry('opencode-ai')).toBe(`${baseUrl}quoted/`); + }); + + it('ignores the current project .npmrc, so a repo cannot pick the registry or proxy', async () => { + await writeFile( + join(workDir, '.npmrc'), + 'registry=https://attacker.invalid/\n@openai:registry=https://attacker.invalid/\nhttps-proxy=http://attacker.invalid:8080\n', + 'utf-8' + ); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(workDir); + try { + expect(resolveRegistry('@openai/codex')).toBe('https://registry.npmjs.org/'); + expect(resolveRegistry('opencode-ai')).toBe('https://registry.npmjs.org/'); + } finally { + cwd.mockRestore(); + } + }); + + it('ignores npm_config_* env vars when launched via npm/npx, which exports the project .npmrc', async () => { + const home = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + // Under npm even npm_config_userconfig is untrusted, so only ~/.npmrc is read. + process.env.HOME = workDir; + process.env.USERPROFILE = workDir; + try { + await writeFile(join(workDir, '.npmrc'), `registry=${baseUrl}\n`, 'utf-8'); + process.env.npm_command = 'exec'; + process.env.npm_config_registry = 'https://attacker.invalid/'; + process.env.npm_config_userconfig = join(workDir, 'attacker-npmrc'); + + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); + } finally { + for (const [key, value] of Object.entries(home)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + it('expands env vars from the user .npmrc', async () => { + process.env.CODEMIE_TEST_HOST = '127.0.0.1'; + try { + await writeUserNpmrc('registry=https://${CODEMIE_TEST_HOST}/npm/\n'); + + expect(resolveRegistry('opencode-ai')).toBe('https://127.0.0.1/npm/'); + } finally { + delete process.env.CODEMIE_TEST_HOST; + } + }); +}); + +describe('fetchLatestVersionFromRegistry', () => { + beforeEach(() => { + process.env.npm_config_registry = baseUrl; + }); + + it("returns the registry's latest version, requesting the encoded scoped path", async () => { + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['/@openai%2fcodex/latest']); + }); + + it('resolves a registry configured under a path prefix (e.g. Artifactory)', async () => { + process.env.npm_config_registry = `${baseUrl}api/npm/remote`; + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['/api/npm/remote/@openai%2fcodex/latest']); + }); + + it.each([ + ['a non-200 status', (res: ServerResponse) => res.writeHead(404).end('{}')], + ['invalid JSON', (res: ServerResponse) => res.writeHead(200).end('proxy login')], + ['a body without a version', (res: ServerResponse) => res.writeHead(200).end('{"name":"x"}')], + ])('returns null for %s', async (_label, respond) => { + handler = (_req, res) => respond(res); + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + }); + + it('returns null when the server never answers within the timeout', async () => { + handler = () => undefined; // hold the request open + + const start = Date.now(); + await expect(fetchFrom('@openai/codex', 200)).resolves.toBeNull(); + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('returns null when a response keeps trickling past the overall deadline', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"version":'); + const timer = setInterval(() => res.write(' '), 50); // never idle, never finished + res.on('close', () => clearInterval(timer)); + }; + + const start = Date.now(); + await expect(fetchFrom('@openai/codex', 300)).resolves.toBeNull(); + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('returns null when the registry is unreachable', async () => { + process.env.npm_config_registry = 'http://127.0.0.1:1/'; + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + }); + + it('sends the request through the configured proxy', async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + // A forward proxy receives the absolute URL of the target. + expect(seenPaths).toEqual(['http://registry.example.invalid/@openai%2fcodex/latest']); + }); + + it('bypasses the proxy for hosts listed in NO_PROXY', async () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; + process.env.NO_PROXY = 'localhost,127.0.0.1'; + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + }); + + it("prefers npm's own proxy setting over HTTP_PROXY, as npm does", async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; // would fail if used + await writeUserNpmrc(`proxy=${baseUrl}\n`); + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['http://registry.example.invalid/@openai%2fcodex/latest']); + }); + + it("tunnels an https registry through the user .npmrc https-proxy, ignoring HTTP(S)_PROXY", async () => { + delete process.env.npm_config_registry; + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; // dead; would fail if used + process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + await writeUserNpmrc(`registry=https://registry.example.invalid/ +https-proxy=${baseUrl} +`); + + // The proxy closes the tunnel, so the lookup fails — but only after asking for the registry host. + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(connectTargets).toEqual(['registry.example.invalid:443']); + }); + + it('fails the lookup instead of going direct when the configured npm proxy is invalid', async () => { + // A loopback host outside the implicit no-proxy list, so the npm proxy setting applies and a + // direct request would reach the local registry and succeed. + process.env.npm_config_registry = baseUrl.replace('127.0.0.1', '[::ffff:127.0.0.1]'); + await writeUserNpmrc('proxy=not a proxy url\n'); + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(seenPaths).toEqual([]); + }); + + it("applies npm's noproxy even when the proxy comes from HTTP_PROXY", async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); + await writeUserNpmrc('noproxy=registry.example.invalid\n'); + + // Goes direct to the (unresolvable) registry, so the proxy never sees the request. + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(seenPaths).toEqual([]); + }); +}); diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts new file mode 100644 index 000000000..1ff90a909 --- /dev/null +++ b/src/utils/__tests__/version-cache.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const state = vi.hoisted(() => ({ dir: '', registry: 'https://registry.npmjs.org/' })); +const fetchLatest = vi.hoisted(() => vi.fn()); +const warn = vi.hoisted(() => vi.fn()); + +vi.mock('../paths.js', () => ({ + getCodemiePath: (name: string) => join(state.dir, name), +})); +vi.mock('../npm-registry.js', () => ({ + fetchLatestVersionFromRegistry: fetchLatest, + resolveRegistry: () => state.registry, +})); +vi.mock('../logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, +})); + +import { getCachedLatestVersion, versionCacheKey } from '../version-cache.js'; + +const PKG = '@openai/codex'; +// Cache entries are keyed by registry and package. +const KEY = versionCacheKey('https://registry.npmjs.org/', PKG); +const SECRET_REGISTRY = 'https://user:s3cret@npm.example.com/tok-SECRET123/'; +const HOUR = 60 * 60 * 1000; +const cacheFile = () => join(state.dir, 'version-cache.json'); + +async function seedCache(version: string, ageMs: number): Promise { + const fetchedAt = new Date(Date.now() - ageMs).toISOString(); + await writeFile(cacheFile(), JSON.stringify({ version: 1, packages: { [KEY]: { version, fetchedAt } } }), 'utf-8'); +} + +describe('getCachedLatestVersion', () => { + beforeEach(async () => { + vi.clearAllMocks(); + state.dir = await mkdtemp(join(tmpdir(), 'codemie-version-cache-')); + state.registry = 'https://registry.npmjs.org/'; + }); + + afterEach(async () => { + await rm(state.dir, { recursive: true, force: true }); + }); + + it('serves a fresh entry without a registry request', async () => { + await seedCache('0.150.0', 1 * HOUR); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.150.0'); + expect(fetchLatest).not.toHaveBeenCalled(); + }); + + it('refreshes an expired entry from the registry and persists the new value', async () => { + await seedCache('0.150.0', 25 * HOUR); + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledWith(PKG, { timeoutMs: 3000 }); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[KEY].version).toBe('0.160.0'); + }); + + it('returns null, not the expired entry, when the lookup fails, and logs it', async () => { + await seedCache('0.150.0', 25 * HOUR); + fetchLatest.mockResolvedValue(null); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith( + '[version-cache] live version lookup failed', + expect.objectContaining({ packageName: PKG }) + ); + }); + + it('treats a fetchedAt in the future as stale rather than fresh forever', async () => { + await seedCache('0.150.0', -48 * HOUR); + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(1); + }); + + it('still returns the fetched version when the cache cannot be written', async () => { + // A directory where the cache file should be makes the write fail. + await mkdir(cacheFile()); + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(warn).toHaveBeenCalledWith( + '[version-cache] failed to persist lookup result', + expect.objectContaining({ packageName: PKG }) + ); + }); + + it('treats a registry answer that is not a version as a failure, never as a cached version', async () => { + fetchLatest.mockResolvedValue('proxy login'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith( + '[version-cache] live version lookup failed', + expect.objectContaining({ reason: 'unparsable registry response' }) + ); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[KEY]).toBeUndefined(); + }); + + it('passes a prerelease string through unchanged so the resolver can reject it', async () => { + fetchLatest.mockResolvedValue('0.161.0-beta.1'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.161.0-beta.1'); + }); + + it.each([ + ['packages is null', { version: 1, packages: null }], + ['packages is an array', { version: 1, packages: [] }], + ['an entry has the wrong shape', { version: 1, packages: { [KEY]: { version: 42 } } }], + ['the file is not valid JSON (e.g. a torn write)', '{"version":1,"pack'], + ])('recovers when %s, and heals the file on the next write', async (_label, content) => { + await writeFile(cacheFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf-8'); + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[KEY].version).toBe('0.160.0'); + }); + + it('skips lookups for 10 minutes after a failure, then retries', async () => { + fetchLatest.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + // A launch right after the failure doesn't wait for the registry again. + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(fetchLatest).toHaveBeenCalledTimes(1); + + // Age the recorded failure past the backoff window. + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + saved.failures[KEY] = new Date(Date.now() - 11 * 60 * 1000).toISOString(); + await writeFile(cacheFile(), JSON.stringify(saved), 'utf-8'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(cacheFile(), 'utf-8')).failures[KEY]).toBeUndefined(); + }); + + it('retries right away after a failure when the caller bypasses the cache', async () => { + fetchLatest.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + await expect(getCachedLatestVersion(PKG, { bypassCache: true })).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(2); + }); + + it('never serves a version cached from one registry to a lookup against another', async () => { + await seedCache('0.150.0', 1 * HOUR); // cached from the public registry + state.registry = 'https://mirror.example/'; + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(1); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[KEY].version).toBe('0.150.0'); + expect(saved.packages[versionCacheKey('https://mirror.example/', PKG)].version).toBe('0.160.0'); + }); + + it('never writes the registry URL, its credentials or path to the cache after a success', async () => { + state.registry = SECRET_REGISTRY; + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + expect(raw).not.toContain('tok-SECRET123'); + expect(raw).not.toContain('user:'); + expect(JSON.parse(raw).packages[versionCacheKey(SECRET_REGISTRY, PKG)].version).toBe('0.160.0'); + }); + + it('never writes the registry URL, its credentials or path to the cache after a failure', async () => { + state.registry = SECRET_REGISTRY; + fetchLatest.mockResolvedValue(null); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + expect(raw).not.toContain('tok-SECRET123'); + expect(raw).not.toContain('user:'); + expect(JSON.parse(raw).failures[versionCacheKey(SECRET_REGISTRY, PKG)]).toEqual(expect.any(String)); + }); + + it('drops legacy raw-URL keys on load so the next write scrubs them, keeping new-format entries', async () => { + const legacyKey = `https://u:s3cret@npm.example.com/|${PKG}`; + const fetchedAt = new Date(Date.now() - 1 * HOUR).toISOString(); + await writeFile( + cacheFile(), + JSON.stringify({ + version: 1, + packages: { [legacyKey]: { version: '0.140.0', fetchedAt }, [KEY]: { version: '0.150.0', fetchedAt } }, + failures: { [legacyKey]: fetchedAt }, + }), + 'utf-8' + ); + fetchLatest.mockResolvedValue('1.0.0'); + + await expect(getCachedLatestVersion('@google/gemini-cli')).resolves.toBe('1.0.0'); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + const saved = JSON.parse(raw); + expect(saved.packages[legacyKey]).toBeUndefined(); + expect(saved.failures[legacyKey]).toBeUndefined(); + expect(saved.packages[KEY].version).toBe('0.150.0'); + }); +}); + +describe('versionCacheKey', () => { + it('produces a placeholder origin, never the raw string, for an unparsable registry', () => { + const key = versionCacheKey('not a url tok-SECRET123', PKG); + expect(key.startsWith('invalid-registry#')).toBe(true); + expect(key).not.toContain('tok-SECRET123'); + expect(key.endsWith(`|${PKG}`)).toBe(true); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index 79a483f82..2afc6dd52 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -620,7 +620,8 @@ export class ConfigLoader { 'assistants', 'skillsSearchUrl', 'claudeAutocompactPct', - 'metrics' + 'metrics', + 'versionChecks' ]; /** diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts new file mode 100644 index 000000000..823a913ad --- /dev/null +++ b/src/utils/npm-registry.ts @@ -0,0 +1,214 @@ +import { get as httpGet, type Agent as HttpAgent } from 'node:http'; +import { get as httpsGet } from 'node:https'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { + IMPLICIT_NO_PROXY, + getEnvNoProxyEntries, + getProxyAgentForUrl, + parseNoProxyRules, + shouldBypassProxy, + splitRules, +} from './system-proxy.js'; +import { logger } from './logger.js'; + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; +const MAX_RESPONSE_BYTES = 1024 * 1024; + +type NpmConfig = Record; + +// An npm proxy the user configured but that cannot be used; the lookup must not then go direct. +class InvalidNpmProxyError extends Error {} + +// Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, optional surrounding quotes, and +// `${VAR}` expansion. +function readNpmrc(file: string): NpmConfig { + if (!existsSync(file)) return {}; + const config: NpmConfig = {}; + try { + for (const rawLine of readFileSync(file, 'utf-8').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) { + value = value.slice(1, -1); + } + config[key] = value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); + } + } catch { + return {}; + } + return config; +} + +// When CodeMie runs under `npm run`/`npx`, npm exports its whole effective config — the current +// project's .npmrc included — as `npm_config_*` env vars, so none of them can be trusted then. +function launchedByNpm(): boolean { + return Boolean(process.env.npm_command || process.env.npm_execpath || process.env.npm_lifecycle_event); +} + +// npm's own precedence for the settings used here: env var > user .npmrc. The env vars are +// skipped under npm (see launchedByNpm). +function npmSetting(config: NpmConfig, key: string): string | undefined { + if (!launchedByNpm()) { + const envKey = `npm_config_${key.replace(/-/g, '_')}`; + const fromEnv = process.env[envKey] || process.env[envKey.toUpperCase()]; + if (fromEnv) return fromEnv; + } + return config[key] || undefined; +} + +// User-level config only — never the current project's .npmrc. The lookup runs on every agent +// launch and its result is cached globally, so a checked-out repo must not be able to pick the +// registry or proxy it comes from: it could plant an old release as the tracked version for every +// project, or route env secrets (`registry=https://host/${TOKEN}/`) to a host of its choosing. +function loadNpmConfig(): NpmConfig { + const userConfig = launchedByNpm() + ? undefined + : process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG; + return readNpmrc(userConfig || join(homedir(), '.npmrc')); +} + +/** + * The registry npm would use for this package per the user's npm config (`@scope:registry` from + * the user .npmrc, then `registry`); a project's .npmrc is deliberately ignored. + */ +export function resolveRegistry(packageName: string): string { + const config = loadNpmConfig(); + const scope = packageName.startsWith('@') ? packageName.split('/')[0] : undefined; + const registry = (scope && config[`${scope}:registry`]) || npmSetting(config, 'registry') || DEFAULT_REGISTRY; + return registry.endsWith('/') ? registry : `${registry}/`; +} + +// NO_PROXY/no_proxy and npm's `noproxy` decide first, whichever proxy would apply. Then npm's own +// `https-proxy`/`proxy` settings win over HTTPS_PROXY/HTTP_PROXY, as they do for npm itself. +// Without them, the shared resolver applies the env vars and then the Windows system proxy / PAC, +// so a registry behind a PAC-only corporate proxy is reachable too. +async function proxyAgentFor(url: URL, config: NpmConfig): Promise { + const isHttps = url.protocol === 'https:'; + const port = Number.parseInt(url.port, 10) || (isHttps ? 443 : 80); + const noProxyRules = parseNoProxyRules([ + ...IMPLICIT_NO_PROXY, + ...getEnvNoProxyEntries(), + ...splitRules(npmSetting(config, 'noproxy')), + ]); + if (shouldBypassProxy(url.hostname, port, noProxyRules)) return undefined; + + const npmProxy = isHttps + ? npmSetting(config, 'https-proxy') || npmSetting(config, 'proxy') + : npmSetting(config, 'proxy'); + if (!npmProxy) return getProxyAgentForUrl(url, { keepAlive: false }); + try { + return isHttps ? new HttpsProxyAgent(npmProxy) : new HttpProxyAgent(npmProxy); + } catch (error) { + // The value is not logged: a proxy URL can carry credentials. + logger.debug('[npm-registry] configured npm proxy is invalid, skipping the lookup', { error: String(error) }); + throw new InvalidNpmProxyError(); + } +} + +// Bounds proxy discovery (a registry read and a PAC fetch on Windows) by the caller's deadline; +// a discovery failure goes direct, as getProxyAgentForUrl itself does. An explicitly configured +// npm proxy that cannot be built fails the lookup instead: going direct would leave the proxy +// the user chose. +async function proxyAgentWithin( + url: URL, + config: NpmConfig, + timeoutMs: number +): Promise<{ agent: HttpAgent | undefined } | null> { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + const discovered = proxyAgentFor(url, config).then( + (agent) => ({ agent }), + (error: unknown) => (error instanceof InvalidNpmProxyError ? null : { agent: undefined }) + ); + try { + return await Promise.race([discovered, timedOut]); + } finally { + clearTimeout(timer); + } +} + +/** + * The `latest` dist-tag version of a package, read straight from the npm registry (one small + * HTTP request instead of spawning `npm view`, which takes 3s+ on Windows). Uses the registry and + * proxy from the user's npm config, else the system proxy. Returns `null` on any failure — + * timeout, network error, non-200, or a response without a version; registries that require + * authentication are not supported. + * + * @param packageName - npm package name, e.g. `@openai/codex` + * @param options.timeoutMs - give up after this long, proxy discovery included + */ +export async function fetchLatestVersionFromRegistry( + packageName: string, + options: { timeoutMs: number } +): Promise { + const startedAt = Date.now(); + let url: URL; + let config: NpmConfig; + try { + config = loadNpmConfig(); + // `@scope/name` must be encoded as `@scope%2fname` for registries other than npmjs. + url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName)); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null; + } + + const proxy = await proxyAgentWithin(url, config, options.timeoutMs); + const remainingMs = options.timeoutMs - (Date.now() - startedAt); + if (!proxy || remainingMs <= 0) { + return null; + } + return requestLatestVersion(url, proxy.agent, remainingMs); +} + +function requestLatestVersion(url: URL, agent: HttpAgent | undefined, timeoutMs: number): Promise { + return new Promise((resolve) => { + const get = url.protocol === 'https:' ? httpsGet : httpGet; + const request = get( + url, + { agent, headers: { accept: 'application/json' }, timeout: timeoutMs }, + (response) => { + if (response.statusCode !== 200) { + response.resume(); + resolve(null); + return; + } + let body = ''; + response.setEncoding('utf-8'); + response.on('data', (chunk: string) => { + body += chunk; + if (body.length > MAX_RESPONSE_BYTES) request.destroy(); + }); + response.on('end', () => { + try { + const version = (JSON.parse(body) as { version?: unknown }).version; + resolve(typeof version === 'string' ? version : null); + } catch { + resolve(null); + } + }); + response.on('error', () => resolve(null)); + } + ); + // `timeout` above only covers an idle socket; this bounds the whole request. + const deadline = setTimeout(() => request.destroy(), timeoutMs); + request.on('close', () => { + clearTimeout(deadline); + resolve(null); // no-op if the response already resolved + }); + request.on('timeout', () => request.destroy()); + request.on('error', () => resolve(null)); + }); +} diff --git a/src/utils/tips.json b/src/utils/tips.json index 15a1b3126..c1c01dcc5 100644 --- a/src/utils/tips.json +++ b/src/utils/tips.json @@ -74,7 +74,7 @@ { "id": "cmd-install-version", "category": "Getting Started", - "message": "Pinpoint control: `codemie install claude 2.0.30` grabs that exact version, while `codemie install claude --supported` picks the one CodeMie tested.", + "message": "Pinpoint control: `codemie install claude 2.0.30` grabs that exact version, while `codemie install claude --supported` picks the one CodeMie is tracking.", "command": "install", "commands": [ "codemie install claude 2.0.30", @@ -93,7 +93,7 @@ { "id": "cmd-update", "category": "Getting Started", - "message": "Worth running `codemie update` once in a while — it brings your installed agents up to their recommended versions.", + "message": "Worth running `codemie update` once in a while — it brings your installed agents up to their tracked versions.", "command": "update", "commands": [ "codemie update" diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts new file mode 100644 index 000000000..7a332b61d --- /dev/null +++ b/src/utils/version-cache.ts @@ -0,0 +1,191 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname } from 'path'; +import { logger } from './logger.js'; +import { fetchLatestVersionFromRegistry, resolveRegistry } from './npm-registry.js'; +import { getCodemiePath } from './paths.js'; + +const TTL_MS = 24 * 60 * 60 * 1000; +// After a failed lookup, skip further lookups for this long, so an offline or firewalled machine +// doesn't wait FETCH_TIMEOUT_MS on every agent launch. Short enough that a restored connection +// is picked up soon; `bypassCache` (explicit `codemie update`) always retries. +const FAILURE_BACKOFF_MS = 10 * 60 * 1000; +// keeps a stale/first-run lookup from stalling agent startup; exported so callers racing this +// lookup against their own timeout (e.g. `codemie setup`) can size their timeout with margin. +export const FETCH_TIMEOUT_MS = 3000; + +// A version as the registry reports it. Prerelease/build suffixes are kept (not stripped) so +// version-resolution can still recognize and reject them. +const NPM_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/; + +// Shape of a key produced by versionCacheKey: `#|`. +const KEY_PATTERN = /^[^|#\s]+#[0-9a-f]{64}\|/; + +/** + * Cache key for a package looked up against a registry. Never embeds the resolved registry URL, + * which may carry credentials or a token in its userinfo or path: only the URL origin (which + * excludes userinfo, path and query) plus a SHA-256 of the full URL, so distinct registries on + * one host still get distinct entries. + * + * @param registry - the resolved registry URL the lookup would ask + * @param packageName - npm package name + * @returns `#|`; origin is `invalid-registry` when the + * URL cannot be parsed + */ +export function versionCacheKey(registry: string, packageName: string): string { + let origin = 'invalid-registry'; + try { + origin = new URL(registry).origin; + } catch { + // keep the placeholder; the raw string must never reach the key + } + if (origin === 'null') origin = 'invalid-registry'; + const hash = createHash('sha256').update(registry).digest('hex'); + return `${origin}#${hash}|${packageName}`; +} + +interface CacheEntry { + version: string; + fetchedAt: string; +} + +interface CacheFile { + version: 1; + packages: Record; + /** When each package's most recent lookup failed (ISO timestamp); cleared by a success. */ + failures: Record; +} + +const filePath = (): string => getCodemiePath('version-cache.json'); +const emptyCache = (): CacheFile => ({ version: 1, packages: {}, failures: {} }); + +// Serializes cache writes within this process so concurrent callers (e.g. `Promise.all` over all +// agents in `checkAllAgentsForUpdates`) can't interleave a read-modify-write and drop each +// other's entries. Across processes the last write wins; the loser just refetches later. +let writeQueue: Promise = Promise.resolve(); +function enqueueCacheWrite(task: () => Promise): Promise { + const result = writeQueue.then(task, task); + writeQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + +function isCacheEntry(value: unknown): value is CacheEntry { + return ( + typeof value === 'object' && + value !== null && + typeof (value as CacheEntry).version === 'string' && + typeof (value as CacheEntry).fetchedAt === 'string' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// Keeps only well-formed entries, so a corrupt or torn file degrades to "not cached" and the +// next successful write repairs it. +async function loadCache(): Promise { + try { + const parsed = JSON.parse(await readFile(filePath(), 'utf-8')) as { packages?: unknown; failures?: unknown } | null; + const cache = emptyCache(); + if (!isRecord(parsed?.packages)) { + return cache; + } + // Keys not in the current format are dropped: legacy raw-URL keys may hold registry + // credentials, and dropping them here lets the next save scrub them from disk. + for (const [name, entry] of Object.entries(parsed.packages)) { + if (KEY_PATTERN.test(name) && isCacheEntry(entry)) cache.packages[name] = entry; + } + if (isRecord(parsed.failures)) { + for (const [name, failedAt] of Object.entries(parsed.failures)) { + if (KEY_PATTERN.test(name) && typeof failedAt === 'string') cache.failures[name] = failedAt; + } + } + return cache; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { error: String(error) }); + } + return emptyCache(); + } +} + +async function saveCache(cache: CacheFile): Promise { + const file = filePath(); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); +} + +// A timestamp younger than `windowMs`; a future timestamp (clock skew, hand-edited file) never +// counts, so it can't pin a value forever. +function isWithin(timestamp: string | undefined, windowMs: number): boolean { + if (!timestamp) return false; + const ageMs = Date.now() - Date.parse(timestamp); + return ageMs >= 0 && ageMs < windowMs; +} + +// Scoped write: re-read at write time (inside the queue) so a concurrent refresh of another +// package isn't clobbered. A failed write is logged and otherwise ignored. +async function updateCache(packageName: string, update: (cache: CacheFile) => void): Promise { + try { + await enqueueCacheWrite(async () => { + const latest = await loadCache(); + update(latest); + await saveCache(latest); + }); + } catch (error) { + logger.warn('[version-cache] failed to persist lookup result', { packageName, error: String(error) }); + } +} + +/** + * The package's `latest` version, served from a 24h cache and fetched from the npm registry on + * a miss. A failed fetch is logged and returns `null` (an expired entry is never presented as + * current), and further lookups are skipped for {@link FAILURE_BACKOFF_MS} so repeated launches + * don't each wait for the timeout. + * + * @param packageName - npm package name, e.g. `@openai/codex` + * @param options.bypassCache - skip the cache (a fresh entry or a recent failure) and always + * fetch; the result is still written back. For explicit user-requested checks such as + * `codemie update`. + * @returns the version string, or `null` when no current value is available + */ +export async function getCachedLatestVersion( + packageName: string, + options: { bypassCache?: boolean } = {} +): Promise { + // Keyed by registry as well as package, so an answer (or failure) from one registry is never + // served to a lookup that would ask another one. + const key = versionCacheKey(resolveRegistry(packageName), packageName); + if (!options.bypassCache) { + const cache = await loadCache(); + const entry = cache.packages[key]; + if (entry && isWithin(entry.fetchedAt, TTL_MS)) return entry.version; + if (isWithin(cache.failures[key], FAILURE_BACKOFF_MS)) { + logger.debug('[version-cache] skipping lookup after a recent failure', { packageName }); + return null; + } + } + + const fetched = await fetchLatestVersionFromRegistry(packageName, { timeoutMs: FETCH_TIMEOUT_MS }); + const version = fetched?.trim(); + if (!version || !NPM_VERSION_PATTERN.test(version)) { + logger.warn('[version-cache] live version lookup failed', { + packageName, + reason: fetched ? 'unparsable registry response' : 'no version returned (offline, registry error or timeout)', + }); + await updateCache(packageName, (cache) => { + cache.failures[key] = new Date().toISOString(); + }); + return null; + } + + await updateCache(packageName, (cache) => { + cache.packages[key] = { version, fetchedAt: new Date().toISOString() }; + delete cache.failures[key]; + }); + return version; +} diff --git a/src/utils/version-utils.ts b/src/utils/version-utils.ts index d83ffa1dd..22ac20bd5 100644 --- a/src/utils/version-utils.ts +++ b/src/utils/version-utils.ts @@ -13,6 +13,24 @@ export interface SemanticVersion { raw: string; // Original version string } +/** + * Extract semver version from a string that may contain extra text + * e.g., "2.0.76 (Claude Code)" -> "2.0.76" + * "v1.2.3-beta" -> "1.2.3" + * + * @param versionString - Version string with potential extra text + * @returns Semantic version string or null if no valid version found + * + * @example + * extractVersion('2.0.76 (Claude Code)') // Returns '2.0.76' + * extractVersion('v1.2.3-beta') // Returns '1.2.3' + * extractVersion('invalid') // Returns null + */ +export function extractVersion(versionString: string): string | null { + const match = versionString.match(/v?(\d+\.\d+\.\d+)/); + return match ? match[1] : null; +} + /** * Parse semantic version string into comparable components * diff --git a/tests/setup/agent-build-setup.ts b/tests/setup/agent-build-setup.ts index 287549f0b..d1cb8efd3 100644 --- a/tests/setup/agent-build-setup.ts +++ b/tests/setup/agent-build-setup.ts @@ -60,44 +60,74 @@ export async function setup(): Promise { process.env.PATH = `${localBin}${pathSep}${process.env.PATH ?? ''}`; } - // Import supported version and plugin class from the just-built dist. - // CLAUDE_SUPPORTED_VERSION is the single source of truth; when a developer - // bumps it locally and runs tests, this block installs the correct version. - const { CLAUDE_SUPPORTED_VERSION, ClaudePlugin } = await import( + // Import the plugin and the version resolver from the just-built dist. The target is the + // version installVersion('supported') itself installs — the live tracked release, or + // 'latest' when that is unknown — so this check and the install can never disagree. + const { ClaudePlugin, ClaudePluginMetadata } = await import( resolve(root, 'dist/agents/plugins/claude/claude.plugin.js') ) as { - CLAUDE_SUPPORTED_VERSION: string; ClaudePlugin: new () => { installVersion(v: string): Promise }; + ClaudePluginMetadata: { + name: string; + npmPackage?: string | null; + supportedVersion?: string; + minimumSupportedVersion?: string; + }; }; + const { resolveSupportedInstallVersion } = await import( + resolve(root, 'dist/agents/core/version-resolution.js') + ) as { + resolveSupportedInstallVersion(input: { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; + minimumSupportedVersion?: string; + }): Promise; + }; + const { compareVersions } = await import( + resolve(root, 'dist/utils/version-utils.js') + ) as { compareVersions(version1: string, version2: string): number }; - let installedVersion: string | null = null; - try { - const versionOutput = execSync('claude --version', { stdio: 'pipe' }).toString().trim(); - const match = versionOutput.match(/^(\d+\.\d+\.\d+)/); - installedVersion = match ? match[1] : null; - } catch { - // Binary not found — installedVersion stays null. - } + const readInstalledClaudeVersion = (): string | null => { + try { + const versionOutput = execSync('claude --version', { stdio: 'pipe' }).toString().trim(); + return versionOutput.match(/^(\d+\.\d+\.\d+)/)?.[1] ?? null; + } catch { + return null; // binary not found + } + }; + + const targetVersion = await resolveSupportedInstallVersion({ + agentName: ClaudePluginMetadata.name, + npmPackage: ClaudePluginMetadata.npmPackage, + fallbackSupportedVersion: ClaudePluginMetadata.supportedVersion, + minimumSupportedVersion: ClaudePluginMetadata.minimumSupportedVersion, + }); + const installedVersion = readInstalledClaudeVersion(); - if (installedVersion === CLAUDE_SUPPORTED_VERSION) { - console.log(`[agent-integration] claude CLI ${CLAUDE_SUPPORTED_VERSION} already installed — skipping.\n`); + // With the tracked version unknown ('latest'), an installed Claude is kept only while it + // still meets the minimum the plugin refuses to launch below; otherwise it is reinstalled. + const minimumVersion = ClaudePluginMetadata.minimumSupportedVersion; + const meetsMinimum = (version: string): boolean => + !minimumVersion || compareVersions(version, minimumVersion) >= 0; + if ( + installedVersion && + (installedVersion === targetVersion || (targetVersion === 'latest' && meetsMinimum(installedVersion))) + ) { + console.log(`[agent-integration] claude CLI ${installedVersion} already installed — skipping.\n`); } else { - if (installedVersion) { - console.log( - `[agent-integration] claude CLI version mismatch (installed: ${installedVersion}, required: ${CLAUDE_SUPPORTED_VERSION}) — installing supported version...`, - ); - } else { - console.log( - `[agent-integration] claude CLI not found — installing supported version ${CLAUDE_SUPPORTED_VERSION}...`, - ); - } + console.log( + installedVersion + ? `[agent-integration] claude CLI version mismatch (installed: ${installedVersion}, tracked: ${targetVersion}) — installing...` + : `[agent-integration] claude CLI not found — installing ${targetVersion}...`, + ); await new ClaudePlugin().installVersion('supported'); // Re-add localBin in case the installer modified PATH during its run. if (!(process.env.PATH ?? '').includes(localBin)) { process.env.PATH = `${localBin}${pathSep}${process.env.PATH ?? ''}`; } execSync('claude --version', { stdio: 'pipe' }); // throws if install genuinely failed - console.log(`[agent-integration] claude CLI ${CLAUDE_SUPPORTED_VERSION} installed.\n`); + console.log(`[agent-integration] claude CLI ${readInstalledClaudeVersion() ?? 'unknown version'} installed.\n`); } // Link the local build to global PATH so `codemie hook` resolves when