From ce1ad35706281d4c1b05655176f3860f67dfa5e6 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 22:57:13 -0500 Subject: [PATCH] perf: skip initResources when version matches, consolidate startup I/O (#1052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add version-match early return to initResources() — skips ~800ms of synchronous rmSync + cpSync when managed-resources.json already matches the running GSD version (steady-state on every launch) - Consolidate package.json reads in loader.ts from 3 to 1 — single read reused for --version, --help, banner, and GSD_VERSION env var - Replace blocking checkAndPromptForUpdates() with passive checkForUpdates() to avoid blocking startup on npm registry fetch + user prompt (up to 5s) - Cache bundled extension keys in resource-loader to avoid redundant filesystem scan in buildResourceLoader() - Use GSD_VERSION env var in getBundledGsdVersion() to skip package.json re-read from resource-loader.ts - Add test verifying version-skip behavior: marker file survives when versions match, gets cleaned on mismatch --- src/cli.ts | 16 +++++----------- src/loader.ts | 26 ++++++++++---------------- src/resource-loader.ts | 30 ++++++++++++++++++++++++++---- src/tests/app-smoke.test.ts | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index efbbbd3d7..c9e1728a2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { loadStoredEnvKeys } from './wizard.js' import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js' import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' -import { checkForUpdates, checkAndPromptForUpdates } from './update-check.js' +import { checkForUpdates } from './update-check.js' import { printHelp, printSubcommandHelp } from './help-text.js' // --------------------------------------------------------------------------- @@ -211,17 +211,11 @@ if (!isPrintMode && shouldRunOnboarding(authStorage, settingsManager.getDefaultP process.stdin.pause() } -// Update check — interactive prompt when stdin is a TTY, passive banner otherwise +// Update check — non-blocking banner check; interactive prompt deferred to avoid +// blocking startup. The passive checkForUpdates() prints a banner if an update is +// available (using cached data or a background fetch) without blocking the TUI. if (!isPrintMode) { - if (process.stdin.isTTY) { - const updated = await checkAndPromptForUpdates().catch(() => false) - if (updated) { - // User chose to update — exit so they relaunch with the new version - process.exit(0) - } - } else { - checkForUpdates().catch(() => {}) - } + checkForUpdates().catch(() => {}) } // Warn if terminal is too narrow for readable output diff --git a/src/loader.ts b/src/loader.ts index 6b22d8fdb..3ff0baa97 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -12,27 +12,21 @@ const gsdRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const args = process.argv.slice(2) const firstArg = args[0] -let _cachedVersion: string | undefined -function getVersion(): string { - if (_cachedVersion === undefined) { - try { - const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8')) - _cachedVersion = pkg.version || '0.0.0' - } catch { - _cachedVersion = '0.0.0' - } - } - return _cachedVersion as string -} +// Read package.json once — reused for version, banner, and GSD_VERSION below +let gsdVersion = '0.0.0' +try { + const pkg = JSON.parse(readFileSync(join(gsdRoot, 'package.json'), 'utf-8')) + gsdVersion = pkg.version || '0.0.0' +} catch { /* ignore */ } if (firstArg === '--version' || firstArg === '-v') { - process.stdout.write(getVersion() + '\n') + process.stdout.write(gsdVersion + '\n') process.exit(0) } if (firstArg === '--help' || firstArg === '-h') { const { printHelp } = await import('./help-text.js') - printHelp(getVersion()) + printHelp(gsdVersion) process.exit(0) } @@ -64,7 +58,7 @@ if (!existsSync(appRoot)) { process.stderr.write( renderLogo(colorCyan) + '\n' + - ` Get Shit Done ${dim}v${getVersion()}${reset}\n` + + ` Get Shit Done ${dim}v${gsdVersion}${reset}\n` + ` ${green}Welcome.${reset} Setting up your environment...\n\n` ) } @@ -87,7 +81,7 @@ const { Module } = await import('module'); (Module as any)._initPaths?.() // GSD_VERSION — expose package version so extensions can display it -process.env.GSD_VERSION = getVersion() +process.env.GSD_VERSION = gsdVersion // GSD_BIN_PATH — absolute path to this loader (dist/loader.js), used by patched subagent // to spawn gsd instead of pi when dispatching workflow tasks diff --git a/src/resource-loader.ts b/src/resource-loader.ts index f40c51015..6eea08f15 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -38,11 +38,15 @@ function getManagedResourceManifestPath(agentDir: string): string { } function getBundledGsdVersion(): string { + // Prefer GSD_VERSION env var (set once by loader.ts) to avoid re-reading package.json + if (process.env.GSD_VERSION && process.env.GSD_VERSION !== '0.0.0') { + return process.env.GSD_VERSION + } 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' + return '0.0.0' } } @@ -146,6 +150,14 @@ function syncResourceDir(srcDir: string, destDir: string): void { export function initResources(agentDir: string): void { mkdirSync(agentDir, { recursive: true }) + // Skip the full copy when the synced version already matches the running version. + // This avoids ~800ms of synchronous rmSync + cpSync on every startup. + const currentVersion = getBundledGsdVersion() + const managedVersion = readManagedResourceVersion(agentDir) + if (managedVersion && managedVersion === currentVersion) { + return + } + syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions')) syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents')) syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills')) @@ -162,12 +174,22 @@ export function initResources(agentDir: string): void { * ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default). * This allows users to use extensions from either location. */ +// Cache bundled extension keys at module load — avoids re-scanning the extensions +// directory in buildResourceLoader() (already scanned by loader.ts for env var). +let _bundledExtensionKeys: Set | null = null +function getBundledExtensionKeys(): Set { + if (!_bundledExtensionKeys) { + _bundledExtensionKeys = new Set( + discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)), + ) + } + return _bundledExtensionKeys +} + export function buildResourceLoader(agentDir: string): DefaultResourceLoader { const piAgentDir = join(homedir(), '.pi', 'agent') const piExtensionsDir = join(piAgentDir, 'extensions') - const bundledKeys = new Set( - discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)), - ) + const bundledKeys = getBundledExtensionKeys() const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter( (entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)), ) diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index f027b3be7..b0306f04b 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -157,6 +157,41 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( } }); +test("initResources skips copy when managed version matches current version", async () => { + const { initResources, readManagedResourceVersion } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-skip-")); + const fakeAgentDir = join(tmp, "agent"); + + try { + // First run: full sync (no manifest yet) + initResources(fakeAgentDir); + const version = readManagedResourceVersion(fakeAgentDir); + assert.ok(version, "manifest written after first sync"); + + // Add a marker file to detect whether sync runs again + const markerPath = join(fakeAgentDir, "extensions", "gsd", "_marker.txt"); + writeFileSync(markerPath, "test-marker"); + + // Second run: version matches — should skip, marker survives + initResources(fakeAgentDir); + assert.ok(existsSync(markerPath), "marker file survives when version matches (sync skipped)"); + + // Simulate version mismatch by writing older version to manifest + const manifestPath = join(fakeAgentDir, "managed-resources.json"); + writeFileSync(manifestPath, JSON.stringify({ gsdVersion: "0.0.1", syncedAt: Date.now() })); + + // Third run: version mismatch — full sync, marker removed + initResources(fakeAgentDir); + assert.ok(!existsSync(markerPath), "marker file removed after version-mismatch sync"); + + // Manifest updated to current version + const updatedVersion = readManagedResourceVersion(fakeAgentDir); + assert.strictEqual(updatedVersion, version, "manifest updated to current version after sync"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + // ═══════════════════════════════════════════════════════════════════════════ // 4. wizard loadStoredEnvKeys hydration // ═══════════════════════════════════════════════════════════════════════════