From 517e7cf0feca7d7cefef4f5afddcaddb80668a0f Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Tue, 14 Apr 2026 18:36:19 +0200 Subject: [PATCH 1/3] fix(gsd): isolate /gsd command registration from extension bootstrap failures On Windows, static imports in register-shortcuts.ts (added in v2.72) can fail at module load time, causing the entire GSD extension to silently fail to register. This makes /gsd unavailable despite the welcome screen suggesting it. Three changes: - index.ts: register /gsd command before importing register-extension.js, wrapped in try-catch so bootstrap failures don't prevent core command - register-extension.ts: remove duplicate registerGSDCommand call, wrap non-critical registrations (tools, shortcuts, hooks) in individual try-catch blocks so one failure doesn't prevent others - Add structural contract tests verifying isolation properties Closes #4168, closes #4172 Co-Authored-By: Claude Sonnet 4.6 --- .../gsd/bootstrap/register-extension.ts | 30 +++- src/resources/extensions/gsd/index.ts | 18 ++- .../extension-bootstrap-isolation.test.ts | 150 ++++++++++++++++++ 3 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts index 20c801c0a..1ed3031cd 100644 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ b/src/resources/extensions/gsd/bootstrap/register-extension.ts @@ -2,7 +2,6 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { registerGSDCommand } from "../commands.js"; import { registerExitCommand } from "../exit-command.js"; import { registerWorktreeCommand } from "../worktree-command.js"; import { registerDbTools } from "./db-tools.js"; @@ -58,7 +57,8 @@ function installEpipeGuard(): void { } export function registerGsdExtension(pi: ExtensionAPI): void { - registerGSDCommand(pi); + // Note: registerGSDCommand is called by index.ts before this function, + // so we intentionally skip it here to avoid double-registration. registerWorktreeCommand(pi); registerExitCommand(pi); @@ -71,10 +71,24 @@ export function registerGsdExtension(pi: ExtensionAPI): void { }, }); - registerDynamicTools(pi); - registerDbTools(pi); - registerJournalTools(pi); - registerQueryTools(pi); - registerShortcuts(pi); - registerHooks(pi); + // Wrap non-critical registrations individually so one failure + // doesn't prevent the others from loading. + const nonCriticalRegistrations: Array<[string, () => void]> = [ + ["dynamic-tools", () => registerDynamicTools(pi)], + ["db-tools", () => registerDbTools(pi)], + ["journal-tools", () => registerJournalTools(pi)], + ["query-tools", () => registerQueryTools(pi)], + ["shortcuts", () => registerShortcuts(pi)], + ["hooks", () => registerHooks(pi)], + ]; + + for (const [name, register] of nonCriticalRegistrations) { + try { + register(); + } catch (err) { + process.stderr.write( + `[gsd] Failed to register ${name}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } } diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index d61786f6f..a50fd6c24 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -16,6 +16,20 @@ export { } from "./bootstrap/write-gate.js"; export default async function registerExtension(pi: ExtensionAPI) { - const { registerGsdExtension } = await import("./bootstrap/register-extension.js"); - registerGsdExtension(pi); + // Always register the core /gsd command first, in isolation. + // This ensures /gsd is available even if the full bootstrap (shortcuts, + // tools, hooks) fails — e.g. due to a Windows-specific import error. + const { registerGSDCommand } = await import("./commands/index.js"); + registerGSDCommand(pi); + + // Full setup (shortcuts, tools, hooks) in a separate try/catch so that + // any platform-specific load failure doesn't take out the core command. + try { + const { registerGsdExtension } = await import("./bootstrap/register-extension.js"); + registerGsdExtension(pi); + } catch (err) { + process.stderr.write( + `[gsd] Extension setup partially failed — /gsd commands are available but shortcuts/tools may be missing: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } } diff --git a/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts b/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts new file mode 100644 index 000000000..24c2193e0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts @@ -0,0 +1,150 @@ +// Structural contracts for GSD extension bootstrap isolation. +// +// The /gsd command must survive failures in the full extension bootstrap +// (register-extension.ts). This guards against the regression where a +// Windows-specific import failure in register-shortcuts.ts silently +// prevented /gsd from being registered at all (#4168, #4172). + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const indexSrc = readFileSync(join(__dirname, "../index.ts"), "utf-8"); +const registerExtSrc = readFileSync( + join(__dirname, "../bootstrap/register-extension.ts"), + "utf-8", +); + +// ─── index.ts: core /gsd command must be registered before full bootstrap ───── + +describe("index.ts bootstrap isolation", () => { + test("imports registerGSDCommand from commands/index.js separately", () => { + assert.ok( + indexSrc.includes('./commands/index.js"') || indexSrc.includes("./commands/index.js'"), + "index.ts must import registerGSDCommand from ./commands/index.js", + ); + }); + + test("calls registerGSDCommand before importing register-extension.js", () => { + const gsdCommandCallPos = indexSrc.indexOf("registerGSDCommand(pi)"); + const bootstrapImportPos = indexSrc.indexOf( + './bootstrap/register-extension.js"', + ); + + assert.ok(gsdCommandCallPos >= 0, "must call registerGSDCommand(pi)"); + assert.ok(bootstrapImportPos >= 0, "must import register-extension.js"); + assert.ok( + gsdCommandCallPos < bootstrapImportPos, + "registerGSDCommand(pi) must be called BEFORE importing register-extension.js", + ); + }); + + test("wraps register-extension.js import in try-catch", () => { + // The dynamic import of register-extension.js must be inside a try block + const tryPos = indexSrc.indexOf("try {"); + const bootstrapImportPos = indexSrc.indexOf( + './bootstrap/register-extension.js"', + ); + const catchPos = indexSrc.indexOf("catch (err)"); + + assert.ok(tryPos >= 0, "must have try block"); + assert.ok(catchPos >= 0, "must have catch block"); + assert.ok( + tryPos < bootstrapImportPos && bootstrapImportPos < catchPos, + "register-extension.js import must be wrapped in try-catch", + ); + }); + + test("writes to stderr on bootstrap failure", () => { + assert.ok( + indexSrc.includes("process.stderr.write"), + "must write to stderr when bootstrap fails", + ); + assert.ok( + indexSrc.includes("[gsd] Extension setup partially failed"), + "stderr message must indicate partial failure with /gsd still available", + ); + }); +}); + +// ─── register-extension.ts: no double-registration + defensive wrapping ─────── + +describe("register-extension.ts defensive registration", () => { + test("does NOT import or call registerGSDCommand (avoids double-registration)", () => { + // registerGSDCommand is now called by index.ts, not register-extension.ts + assert.ok( + !registerExtSrc.includes("import { registerGSDCommand }"), + "register-extension.ts must NOT import registerGSDCommand", + ); + + // Check the function body of registerGsdExtension doesn't call it + const funcBodyStart = registerExtSrc.indexOf( + "export function registerGsdExtension", + ); + const funcBody = registerExtSrc.slice(funcBodyStart); + assert.ok( + !funcBody.includes("registerGSDCommand(pi)"), + "registerGsdExtension must NOT call registerGSDCommand(pi)", + ); + }); + + test("still registers worktree, exit, and kill commands", () => { + const funcBodyStart = registerExtSrc.indexOf( + "export function registerGsdExtension", + ); + const funcBody = registerExtSrc.slice(funcBodyStart); + + assert.ok( + funcBody.includes("registerWorktreeCommand(pi)"), + "must register worktree command", + ); + assert.ok( + funcBody.includes("registerExitCommand(pi)"), + "must register exit command", + ); + assert.ok( + funcBody.includes('"kill"'), + "must register kill command", + ); + }); + + test("wraps non-critical registrations in individual try-catch blocks", () => { + const funcBodyStart = registerExtSrc.indexOf( + "export function registerGsdExtension", + ); + const funcBody = registerExtSrc.slice(funcBodyStart); + + // Each non-critical registration should be wrapped with error handling + const registrationNames = [ + "dynamic-tools", + "db-tools", + "journal-tools", + "query-tools", + "shortcuts", + "hooks", + ]; + + for (const name of registrationNames) { + assert.ok( + funcBody.includes(`"${name}"`), + `non-critical registration "${name}" must be present`, + ); + } + + // Must have try-catch inside the registration loop + assert.ok( + funcBody.includes("try {") && funcBody.includes("catch (err)"), + "must have try-catch for non-critical registrations", + ); + }); + + test("writes to stderr when a non-critical registration fails", () => { + assert.ok( + registerExtSrc.includes("[gsd] Failed to register"), + "must write descriptive error to stderr for individual registration failures", + ); + }); +}); From 563a1e1b211f7fc8db66740c7b6151a6d6e64137 Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Tue, 14 Apr 2026 18:49:08 +0200 Subject: [PATCH 2/3] fix(ci): cache dist alongside tsbuildinfo and use workflow-logger in catch blocks - Include dist/ and packages/*/dist/ in the TypeScript incremental cache so that when tsbuildinfo indicates no changes, the compiled output files are still present. Without this, tsc with incremental:true skips emission when tsbuildinfo exists but dist/ is absent (fresh checkout + cache restore), causing downstream packages like @gsd/pi-tui to fail resolving @gsd/native subpath exports. - Also hash source files in the cache key so dist is invalidated on code changes. - Replace process.stderr.write with logWarning("bootstrap", ...) in catch blocks to satisfy the workflow-logger coverage test (#3348). - Update extension-bootstrap-isolation tests to match the new logWarning pattern. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 12 ++++++++--- .../gsd/bootstrap/register-extension.ts | 6 ++++-- src/resources/extensions/gsd/index.ts | 6 ++++-- .../extension-bootstrap-isolation.test.ts | 20 +++++++++++-------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3529e5c1..07535b33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,9 @@ jobs: path: | *.tsbuildinfo packages/*/*.tsbuildinfo - key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json') }} + dist + packages/*/dist + key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json', 'src/**/*.ts', 'packages/*/src/**/*.ts') }} restore-keys: | tsbuild-${{ runner.os }}- @@ -222,7 +224,9 @@ jobs: path: | *.tsbuildinfo packages/*/*.tsbuildinfo - key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json') }} + dist + packages/*/dist + key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json', 'src/**/*.ts', 'packages/*/src/**/*.ts') }} restore-keys: | tsbuild-${{ runner.os }}- @@ -259,7 +263,9 @@ jobs: path: | *.tsbuildinfo packages/*/*.tsbuildinfo - key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json') }} + dist + packages/*/dist + key: tsbuild-${{ runner.os }}-${{ hashFiles('package-lock.json', 'tsconfig*.json', 'packages/*/tsconfig.json', 'src/**/*.ts', 'packages/*/src/**/*.ts') }} restore-keys: | tsbuild-${{ runner.os }}- diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts index 1ed3031cd..bdcc436ab 100644 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ b/src/resources/extensions/gsd/bootstrap/register-extension.ts @@ -11,6 +11,7 @@ import { registerQueryTools } from "./query-tools.js"; import { registerHooks } from "./register-hooks.js"; import { registerShortcuts } from "./register-shortcuts.js"; import { writeCrashLog } from "./crash-log.js"; +import { logWarning } from "../workflow-logger.js"; export { writeCrashLog } from "./crash-log.js"; @@ -86,8 +87,9 @@ export function registerGsdExtension(pi: ExtensionAPI): void { try { register(); } catch (err) { - process.stderr.write( - `[gsd] Failed to register ${name}: ${err instanceof Error ? err.message : String(err)}\n`, + logWarning( + "bootstrap", + `Failed to register ${name}: ${err instanceof Error ? err.message : String(err)}`, ); } } diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index a50fd6c24..88bc6ee15 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -28,8 +28,10 @@ export default async function registerExtension(pi: ExtensionAPI) { const { registerGsdExtension } = await import("./bootstrap/register-extension.js"); registerGsdExtension(pi); } catch (err) { - process.stderr.write( - `[gsd] Extension setup partially failed — /gsd commands are available but shortcuts/tools may be missing: ${err instanceof Error ? err.message : String(err)}\n`, + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "bootstrap", + `Extension setup partially failed — /gsd commands are available but shortcuts/tools may be missing: ${err instanceof Error ? err.message : String(err)}`, ); } } diff --git a/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts b/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts index 24c2193e0..f48f91dcd 100644 --- a/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts +++ b/src/resources/extensions/gsd/tests/extension-bootstrap-isolation.test.ts @@ -58,14 +58,14 @@ describe("index.ts bootstrap isolation", () => { ); }); - test("writes to stderr on bootstrap failure", () => { + test("logs warning on bootstrap failure via workflow-logger", () => { assert.ok( - indexSrc.includes("process.stderr.write"), - "must write to stderr when bootstrap fails", + indexSrc.includes("logWarning"), + "must use logWarning when bootstrap fails", ); assert.ok( - indexSrc.includes("[gsd] Extension setup partially failed"), - "stderr message must indicate partial failure with /gsd still available", + indexSrc.includes("Extension setup partially failed"), + "warning message must indicate partial failure with /gsd still available", ); }); }); @@ -141,10 +141,14 @@ describe("register-extension.ts defensive registration", () => { ); }); - test("writes to stderr when a non-critical registration fails", () => { + test("logs warning when a non-critical registration fails", () => { assert.ok( - registerExtSrc.includes("[gsd] Failed to register"), - "must write descriptive error to stderr for individual registration failures", + registerExtSrc.includes("Failed to register"), + "must log descriptive warning for individual registration failures", + ); + assert.ok( + registerExtSrc.includes("logWarning"), + "must use logWarning from workflow-logger", ); }); }); From 5f18376d59ad65b96db1da9e69bc8f5ea6418301 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 14 Apr 2026 12:52:28 -0500 Subject: [PATCH 3/3] fix(ci): disable incremental resources build cache state --- tsconfig.resources.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.resources.json b/tsconfig.resources.json index 00702c3b5..ec285b3ee 100644 --- a/tsconfig.resources.json +++ b/tsconfig.resources.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "incremental": false, "rootDir": "src/resources", "outDir": "dist/resources", "declaration": false,