diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts index 16af7230b..1797b2cd9 100644 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -25,6 +25,26 @@ import { getAutoWorktreePath } from "./auto-worktree.js"; import { projectRoot } from "./commands/context.js"; import { loadPrompt } from "./prompt-loader.js"; +const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/gsd-pi/latest"; +const UPDATE_FETCH_TIMEOUT_MS = 5000; + +async function fetchLatestVersionForCommand(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS); + + try { + const res = await fetch(UPDATE_REGISTRY_URL, { signal: controller.signal }); + if (!res.ok) return null; + const data = (await res.json()) as { version?: string }; + const latest = typeof data.version === "string" ? data.version.trim().replace(/^v/, "") : ""; + return latest.length > 0 ? latest : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md"); const workflow = readFileSync(workflowPath, "utf-8"); @@ -394,13 +414,8 @@ export async function handleUpdate(ctx: ExtensionCommandContext): Promise ctx.ui.notify(`Current version: v${current}\nChecking npm registry...`, "info"); - let latest: string; - try { - latest = execSync(`npm view ${NPM_PACKAGE} version`, { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { + const latest = await fetchLatestVersionForCommand(); + if (!latest) { ctx.ui.notify("Failed to reach npm registry. Check your network connection.", "error"); return; } diff --git a/src/tests/update-check.test.ts b/src/tests/update-check.test.ts index caa712533..40d2c5f28 100644 --- a/src/tests/update-check.test.ts +++ b/src/tests/update-check.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { createServer } from 'node:http' -import { compareSemver, readUpdateCache, writeUpdateCache, checkForUpdates } from '../update-check.js' +import { compareSemver, readUpdateCache, writeUpdateCache, checkForUpdates, fetchLatestVersionFromRegistry } from '../update-check.js' // --------------------------------------------------------------------------- // compareSemver @@ -315,3 +315,23 @@ test('checkForUpdates handles missing version field in response', async (t) => { assert.ok(!called, 'onUpdate should not be called when response has no version') }) + +test('fetchLatestVersionFromRegistry returns the registry version string', async (t) => { + const registry = await startMockRegistry({ version: '2.67.0' }) + t.after(async () => { + await registry.close() + }) + + const latest = await fetchLatestVersionFromRegistry(registry.url, 5000) + assert.equal(latest, '2.67.0') +}) + +test('fetchLatestVersionFromRegistry returns null for blank version strings', async (t) => { + const registry = await startMockRegistry({ version: '' }) + t.after(async () => { + await registry.close() + }) + + const latest = await fetchLatestVersionFromRegistry(registry.url, 5000) + assert.equal(latest, null) +}) diff --git a/src/tests/update-cmd-diagnostics.test.ts b/src/tests/update-cmd-diagnostics.test.ts index 71fff7b36..8f3c5c088 100644 --- a/src/tests/update-cmd-diagnostics.test.ts +++ b/src/tests/update-cmd-diagnostics.test.ts @@ -18,10 +18,17 @@ test("update-cmd prints latest version before comparison (#3445)", () => { assert.ok(latestPrintIdx < comparisonIdx, "Must print latest BEFORE comparison"); }); -test("update-cmd bypasses npm cache (#3445)", () => { +test("update commands use the registry fetch helper instead of npm view (#3806)", () => { const src = readFileSync(join(__dirname, "..", "update-cmd.ts"), "utf-8"); + const handlerSrc = readFileSync(join(__dirname, "..", "resources", "extensions", "gsd", "commands-handlers.ts"), "utf-8"); assert.ok( - src.includes("npm_config_cache"), - "Must clear npm cache env to bypass stale registry data", + src.includes("fetchLatestVersionFromRegistry"), + "update-cmd should use the shared registry fetch helper", ); + assert.ok(!src.includes("npm view "), "update-cmd should no longer shell out to npm view"); + assert.ok( + handlerSrc.includes("fetchLatestVersionForCommand"), + "/gsd update should fetch the latest version through a registry helper too", + ); + assert.ok(!handlerSrc.includes("npm view "), "/gsd update should no longer shell out to npm view"); }); diff --git a/src/update-check.ts b/src/update-check.ts index 784eeb900..d560c318b 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -8,6 +8,7 @@ const CACHE_FILE = join(appRoot, '.update-check') const NPM_PACKAGE_NAME = 'gsd-pi' const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours const FETCH_TIMEOUT_MS = 5000 +const DEFAULT_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest` interface UpdateCheckCache { lastCheck: number @@ -47,6 +48,32 @@ export function writeUpdateCache(cache: UpdateCheckCache, cachePath: string = CA } } +function normalizeLatestVersion(version: unknown): string | null { + if (typeof version !== 'string') return null + const trimmed = version.trim().replace(/^v/, '') + return trimmed.length > 0 ? trimmed : null +} + +export async function fetchLatestVersionFromRegistry( + registryUrl: string = DEFAULT_REGISTRY_URL, + fetchTimeoutMs: number = FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs) + + try { + const res = await fetch(registryUrl, { signal: controller.signal }) + if (!res.ok) return null + + const data = (await res.json()) as { version?: string } + return normalizeLatestVersion(data.version) + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + function printUpdateBanner(current: string, latest: string): void { process.stderr.write( ` ${chalk.yellow('Update available:')} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` + @@ -70,7 +97,7 @@ export interface UpdateCheckOptions { export async function checkForUpdates(options: UpdateCheckOptions = {}): Promise { const currentVersion = options.currentVersion || process.env.GSD_VERSION || '0.0.0' const cachePath = options.cachePath || CACHE_FILE - const registryUrl = options.registryUrl || `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest` + const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL const checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS const fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS const onUpdate = options.onUpdate || printUpdateBanner @@ -84,18 +111,8 @@ export async function checkForUpdates(options: UpdateCheckOptions = {}): Promise return } - // Fetch latest version from npm registry - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs) - try { - const res = await fetch(registryUrl, { signal: controller.signal }) - clearTimeout(timeout) - - if (!res.ok) return - - const data = (await res.json()) as { version?: string } - const latestVersion = data.version + const latestVersion = await fetchLatestVersionFromRegistry(registryUrl, fetchTimeoutMs) if (!latestVersion) return writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath) @@ -105,8 +122,6 @@ export async function checkForUpdates(options: UpdateCheckOptions = {}): Promise } } catch { // Network error or timeout — silently ignore, don't block startup - } finally { - clearTimeout(timeout) } } @@ -123,7 +138,7 @@ const PROMPT_TIMEOUT_MS = 30_000 export async function checkAndPromptForUpdates(options: UpdateCheckOptions = {}): Promise { const currentVersion = options.currentVersion || process.env.GSD_VERSION || '0.0.0' const cachePath = options.cachePath || CACHE_FILE - const registryUrl = options.registryUrl || `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest` + const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL const checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS const fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS @@ -134,22 +149,13 @@ export async function checkAndPromptForUpdates(options: UpdateCheckOptions = {}) if (cache && Date.now() - cache.lastCheck < checkIntervalMs) { latestVersion = cache.latestVersion } else { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs) try { - const res = await fetch(registryUrl, { signal: controller.signal }) - clearTimeout(timeout) - if (res.ok) { - const data = (await res.json()) as { version?: string } - if (data.version) { - latestVersion = data.version - writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath) - } + latestVersion = await fetchLatestVersionFromRegistry(registryUrl, fetchTimeoutMs) + if (latestVersion) { + writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath) } } catch { // Network unavailable — silently skip - } finally { - clearTimeout(timeout) } } diff --git a/src/update-cmd.ts b/src/update-cmd.ts index 9534fd9f6..18dcd0c48 100644 --- a/src/update-cmd.ts +++ b/src/update-cmd.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process' -import { compareSemver } from './update-check.js' +import { compareSemver, fetchLatestVersionFromRegistry } from './update-check.js' const NPM_PACKAGE = 'gsd-pi' @@ -14,15 +14,8 @@ export async function runUpdate(): Promise { process.stdout.write(`${dim}Current version:${reset} v${current}\n`) process.stdout.write(`${dim}Checking npm registry...${reset}\n`) - // Fetch latest version — bypass npm client cache to avoid stale results (#3445) - let latest: string - try { - latest = execSync(`npm view ${NPM_PACKAGE} version --fetch-retry-mintimeout=3000`, { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - env: { ...process.env, npm_config_cache: '' }, - }).trim() - } catch { + const latest = await fetchLatestVersionFromRegistry() + if (!latest) { process.stderr.write(`${yellow}Failed to reach npm registry.${reset}\n`) process.exit(1) }