fix(update): fetch latest version from registry

This commit is contained in:
mastertyko 2026-04-09 18:39:16 +02:00
parent 335535b506
commit c1f732fae8
5 changed files with 89 additions and 48 deletions

View file

@ -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<string | null> {
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<void>
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;
}

View file

@ -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)
})

View file

@ -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");
});

View file

@ -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<string | null> {
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<void> {
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<boolean> {
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)
}
}

View file

@ -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<void> {
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)
}