diff --git a/src/cli.ts b/src/cli.ts index e5142cd57..fa70b501b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' -import { initResources, buildResourceLoader } from './resource-loader.js' +import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js' import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys } from './wizard.js' import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js' @@ -35,6 +35,26 @@ interface CliFlags { messages: string[] } +function exitIfManagedResourcesAreNewer(currentAgentDir: string): void { + const currentVersion = process.env.GSD_VERSION || '0.0.0' + const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion) + if (!managedVersion) { + return + } + + const yellow = '\x1b[33m' + const dim = '\x1b[2m' + const reset = '\x1b[0m' + const bold = '\x1b[1m' + + process.stderr.write( + `[gsd] ${yellow}Version mismatch detected${reset}\n` + + `[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` + + `[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`, + ) + process.exit(1) +} + function parseCliArgs(argv: string[]): CliFlags { const flags: CliFlags = { extensions: [], messages: [] } const args = argv.slice(2) // skip node + script @@ -232,6 +252,7 @@ if (isPrintMode) { } } + exitIfManagedResourcesAreNewer(agentDir) initResources(agentDir) const resourceLoader = new DefaultResourceLoader({ agentDir, @@ -316,6 +337,7 @@ const sessionManager = cliFlags.continue ? SessionManager.continueRecent(cwd, projectSessionsDir) : SessionManager.create(cwd, projectSessionsDir) +exitIfManagedResourcesAreNewer(agentDir) initResources(agentDir) const resourceLoader = buildResourceLoader(agentDir) await resourceLoader.reload() diff --git a/src/resource-loader.ts b/src/resource-loader.ts index c7c00331c..676e52979 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { compareSemver } from './update-check.js' // Resolve resources directory — prefer dist/resources/ (stable, set at build time) // over src/resources/ (live working tree, changes with git branch). @@ -17,6 +18,11 @@ const distResources = join(packageRoot, 'dist', 'resources') const srcResources = join(packageRoot, 'src', 'resources') const resourcesDir = existsSync(distResources) ? distResources : srcResources const bundledExtensionsDir = join(resourcesDir, 'extensions') +const resourceVersionManifestName = 'managed-resources.json' + +interface ManagedResourceManifest { + gsdVersion: string +} function isExtensionFile(name: string): boolean { return name.endsWith('.ts') || name.endsWith('.js') @@ -82,6 +88,41 @@ function getExtensionKey(entryPath: string, extensionsDir: string): string { return relPath.split(/[\\/]/)[0] } +function getManagedResourceManifestPath(agentDir: string): string { + return join(agentDir, resourceVersionManifestName) +} + +function getBundledGsdVersion(): string { + try { + const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8')) + return typeof pkg?.version === 'string' ? pkg.version : '0.0.0' + } catch { + return process.env.GSD_VERSION || '0.0.0' + } +} + +function writeManagedResourceManifest(agentDir: string): void { + const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion() } + writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest)) +} + +export function readManagedResourceVersion(agentDir: string): string | null { + try { + const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest + return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null + } catch { + return null + } +} + +export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null { + const managedVersion = readManagedResourceVersion(agentDir) + if (!managedVersion) { + return null + } + return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null +} + /** * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch. * @@ -116,6 +157,8 @@ export function initResources(agentDir: string): void { if (existsSync(srcSkills)) { cpSync(srcSkills, destSkills, { recursive: true, force: true }) } + + writeManagedResourceManifest(agentDir) } /** diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 1c94190c0..69893d360 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -126,7 +126,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { // ═══════════════════════════════════════════════════════════════════════════ test("initResources syncs extensions, agents, and skills to target dir", async () => { - const { initResources } = await import("../resource-loader.ts"); + const { initResources, readManagedResourceVersion } = await import("../resource-loader.ts"); const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-test-")); const fakeAgentDir = join(tmp, "agent"); @@ -146,6 +146,10 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( // Skills synced assert.ok(existsSync(join(fakeAgentDir, "skills")), "skills directory synced"); + // Version manifest synced + const managedVersion = readManagedResourceVersion(fakeAgentDir); + assert.ok(managedVersion, "managed resource version written"); + // Idempotent: run again, no crash initResources(fakeAgentDir); assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "idempotent re-sync works"); diff --git a/src/tests/integration/pack-install.test.ts b/src/tests/integration/pack-install.test.ts index 7afcfa586..4abd4cbfb 100644 --- a/src/tests/integration/pack-install.test.ts +++ b/src/tests/integration/pack-install.test.ts @@ -12,7 +12,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createGunzip } from "node:zlib"; @@ -229,3 +229,48 @@ test("gsd launches and loads extensions without errors", async () => { "no ERR_MODULE_NOT_FOUND", ); }); + +test("gsd exits early with a clear message when synced resources are newer than the binary", async () => { + const fakeHome = mkdtempSync(join(tmpdir(), "gsd-version-skew-")); + const fakeAgentDir = join(fakeHome, ".gsd", "agent"); + mkdirSync(fakeAgentDir, { recursive: true }); + writeFileSync( + join(fakeAgentDir, "managed-resources.json"), + JSON.stringify({ gsdVersion: "999.0.0" }), + ); + + try { + const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + let stderr = ""; + const child = spawn("node", ["dist/loader.js"], { + cwd: projectRoot, + env: { + ...process.env, + HOME: fakeHome, + BRAVE_API_KEY: "test", + BRAVE_ANSWERS_KEY: "test", + CONTEXT7_API_KEY: "test", + JINA_API_KEY: "test", + TAVILY_API_KEY: "test", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.stdin.end(); + child.on("close", (code) => { + resolve({ code, stderr }); + }); + }); + + assert.equal(result.code, 1, "startup exits with code 1 on version skew"); + assert.match(result.stderr, /Version mismatch detected/, "prints a friendly skew header"); + assert.match(result.stderr, /npm install -g gsd-pi@latest|gsd update/, "prints upgrade guidance"); + assert.doesNotMatch(result.stderr, /\[gsd\] Extension load error/, "fails before extension loading"); + } finally { + rmSync(fakeHome, { recursive: true, force: true }); + } +});