fix: prune stale env-utils.js from extensions root, preventing startup load error (#1655)

* fix: prune stale env-utils.js from extensions root, preventing startup load error

- Move env-utils.ts from extensions/ root into gsd/ subdirectory
- Update all import paths to reflect new location
- Add manifest-based tracking in resource-loader to record which root-level
  extension files are installed, so future upgrades can detect and prune files
  that get removed or relocated (preventing recurrence)
- Add known-stale fallback for pre-manifest upgrades (explicitly removes
  env-utils.js which was moved into gsd/ in this release)
- Remove re-export block from auto.ts that referenced relocated symbols
- Clean up session_start handler in native-search.ts (remove provider diagnostics
  that were duplicating info already shown by model_select)
- Update welcome-screen layout to two-panel bar design for visual consistency

* fix: resolve PR1655 extension load and compile regressions

* fix: remove duplicate _clearGsdRootCache export

* fix: restore native-search session_start diagnostics
This commit is contained in:
Jeremy McSpadden 2026-03-20 16:43:06 -05:00 committed by GitHub
parent 83bacfcc94
commit b8d08f3667
5 changed files with 84 additions and 13 deletions

View file

@ -33,6 +33,13 @@ interface ManagedResourceManifest {
syncedAt?: number
/** Content fingerprint of bundled resources — detects same-version content changes. */
contentHash?: string
/**
* Root-level files installed in extensions/ by this GSD version.
* Used on the next upgrade to detect and prune files that were removed or
* moved into a subdirectory, preventing orphaned non-extension files from
* causing extension load errors.
*/
installedExtensionRootFiles?: string[]
}
export { discoverExtensionEntryPaths } from './extension-discovery.js'
@ -60,10 +67,22 @@ function getBundledGsdVersion(): string {
}
function writeManagedResourceManifest(agentDir: string): void {
// Record root-level files currently in the bundled extensions source so that
// future upgrades can detect and prune any that get removed or moved.
let installedExtensionRootFiles: string[] = []
try {
if (existsSync(bundledExtensionsDir)) {
installedExtensionRootFiles = readdirSync(bundledExtensionsDir, { withFileTypes: true })
.filter(e => e.isFile())
.map(e => e.name)
}
} catch { /* non-fatal */ }
const manifest: ManagedResourceManifest = {
gsdVersion: getBundledGsdVersion(),
syncedAt: Date.now(),
contentHash: computeResourceFingerprint(),
installedExtensionRootFiles,
}
writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest))
}
@ -266,6 +285,51 @@ function ensureNodeModulesSymlink(agentDir: string): void {
}
}
/**
* Prune root-level extension files that were installed by a previous GSD version
* but have since been removed or relocated to a subdirectory.
*
* Two strategies:
* 1. Manifest-based (preferred): the manifest records which root files were installed
* last time; any that are no longer in the current bundle are deleted.
* 2. Known-stale fallback: for upgrades from versions before manifest tracking,
* explicitly delete files known to have been moved (e.g. env-utils.js gsd/).
*/
function pruneRemovedBundledExtensions(
manifest: ManagedResourceManifest | null,
agentDir: string,
): void {
const extensionsDir = join(agentDir, 'extensions')
if (!existsSync(extensionsDir)) return
// Current bundled root-level files (what the new version provides)
const currentSourceFiles = new Set<string>()
try {
if (existsSync(bundledExtensionsDir)) {
for (const e of readdirSync(bundledExtensionsDir, { withFileTypes: true })) {
if (e.isFile()) currentSourceFiles.add(e.name)
}
}
} catch { /* non-fatal */ }
const removeIfStale = (fileName: string) => {
if (currentSourceFiles.has(fileName)) return // still in bundle, not stale
const stale = join(extensionsDir, fileName)
try { if (existsSync(stale)) rmSync(stale, { force: true }) } catch { /* non-fatal */ }
}
if (manifest?.installedExtensionRootFiles) {
// Manifest-based: remove previously-installed root files that are no longer bundled
for (const prevFile of manifest.installedExtensionRootFiles) {
removeIfStale(prevFile)
}
} else {
// Fallback: explicitly remove known stale files from pre-manifest-tracking versions
// env-utils.js was moved from extensions/ root → gsd/ in v2.39.x (#1634)
removeIfStale('env-utils.js')
}
}
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
@ -284,11 +348,18 @@ function ensureNodeModulesSymlink(agentDir: string): void {
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true })
const currentVersion = getBundledGsdVersion()
const manifest = readManagedResourceManifest(agentDir)
// Always prune root-level extension files that were removed from the bundle.
// This is cheap (a few existence checks + at most one rmSync) and must run
// unconditionally so that stale files left by a previous version are cleaned
// up even when the version/hash match causes the full sync to be skipped.
pruneRemovedBundledExtensions(manifest, agentDir)
// Skip the full copy when both version AND content fingerprint match.
// Version-only checks miss same-version content changes (npm link dev workflow,
// hotfixes within a release). The content hash catches those at ~1ms cost.
const currentVersion = getBundledGsdVersion()
const manifest = readManagedResourceManifest(agentDir)
if (manifest && manifest.gsdVersion === currentVersion) {
// Version matches — check content fingerprint for same-version staleness.
const currentHash = computeResourceFingerprint()

View file

@ -70,7 +70,7 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis
// Re-export from env-utils.ts so existing consumers still work.
// The implementation lives in env-utils.ts to avoid pulling @gsd/pi-tui
// into modules that only need env-checking (e.g. files.ts during reports).
import { checkExistingEnvKeys } from "./env-utils.js";
import { checkExistingEnvKeys } from "./gsd/env-utils.js";
export { checkExistingEnvKeys };
/**

View file

@ -1186,15 +1186,6 @@ function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryCo
};
}
// Re-export recovery functions for external consumers
export {
resolveExpectedArtifactPath,
verifyExpectedArtifact,
writeBlockerPlaceholder,
skipExecuteTask,
buildLoopRemediationSteps,
} from "./auto-recovery.js";
/**
* Test-only: expose skip-loop state for unit tests.
* Not part of the public API.
@ -1330,3 +1321,12 @@ export async function dispatchHookUnit(
// Direct phase dispatch → auto-direct-dispatch.ts
export { dispatchDirectPhase } from "./auto-direct-dispatch.js";
// Re-export recovery functions for external consumers
export {
resolveExpectedArtifactPath,
verifyExpectedArtifact,
writeBlockerPlaceholder,
skipExecuteTask,
buildLoopRemediationSteps,
} from "./auto-recovery.js";

View file

@ -20,7 +20,7 @@ import type {
ManifestStatus,
} from './types.js';
import { checkExistingEnvKeys } from '../env-utils.js';
import { checkExistingEnvKeys } from './env-utils.js';
import { parseRoadmapSlices } from './roadmap-slices.js';
import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js';
import { debugTime, debugCount } from './debug-logger.js';