From 488e4b51100a478cd00e61e6ad920ba74668b778 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 12 Apr 2026 02:12:13 -0500 Subject: [PATCH] fix(cli): include all internal node_modules entries in pnpm merged dir PR #3564 narrowed the internal overlay to @gsd* prefixes only, which dropped non-hoisted optional deps like @anthropic-ai/claude-agent-sdk from the merged ~/.gsd/agent/node_modules directory. Revert to overlaying all non-dotfile internal entries so optional deps resolve correctly. --- src/resource-loader.ts | 9 +++-- src/tests/node-modules-symlink.test.ts | 51 +++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 1309578e2..7ddc3b7ee 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -399,12 +399,15 @@ function reconcileMergedNodeModules( console.error(`[gsd] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`) } - // Overlay @gsd* workspace scopes from internal node_modules + // Overlay internal node_modules entries that weren't hoisted. + // This covers @gsd/* workspace packages AND optional deps like + // @anthropic-ai/claude-agent-sdk that npm keeps internal. try { for (const entry of readdirSync(internal, { withFileTypes: true })) { - if (!entry.name.startsWith('@gsd')) continue + if (entry.name.startsWith('.')) continue const link = join(agentNodeModules, entry.name) - try { lstatSync(link); unlinkSync(link) } catch { /* didn't exist */ } + // Replace hoisted symlink with internal version (internal takes precedence) + try { lstatSync(link); unlinkSync(link) } catch { /* didn't exist — will create below */ } try { symlinkSync(join(internal, entry.name), link); linkedCount++ } catch { /* skip individual */ } } } catch (err) { diff --git a/src/tests/node-modules-symlink.test.ts b/src/tests/node-modules-symlink.test.ts index a22f10910..56a9d4a03 100644 --- a/src/tests/node-modules-symlink.test.ts +++ b/src/tests/node-modules-symlink.test.ts @@ -147,9 +147,9 @@ test("pnpm layout: merged node_modules contains entries from both hoisted and in symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name)); } - // Overlay @gsd* workspace scopes from internal (these take precedence) + // Overlay all non-dotfile entries from internal (these take precedence) for (const entry of readdirSync(internal, { withFileTypes: true })) { - if (!entry.name.startsWith("@gsd")) continue; + if (entry.name.startsWith(".")) continue; const link = join(agentNodeModules, entry.name); try { lstatSync(link); unlinkSync(link); } catch { /* didn't exist */ } symlinkSync(join(internal, entry.name), link); @@ -173,6 +173,53 @@ test("pnpm layout: merged node_modules contains entries from both hoisted and in assert.equal(gsdTarget, join(internal, "@gsd"), "@gsd should point to internal node_modules"); }); +test("pnpm layout: non-@gsd internal deps (e.g. @anthropic-ai) are included in merged dir", (t) => { + // Regression: PR #3564 narrowed the internal overlay to @gsd* only, + // dropping optionalDependencies like @anthropic-ai/claude-agent-sdk + // that npm installs internally rather than hoisting. + const tmp = mkdtempSync(join(tmpdir(), "gsd-pnpm-internal-optional-")); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + const hoisted = join(tmp, "node_modules"); + const pkgRoot = join(hoisted, "gsd-pi"); + const internal = join(pkgRoot, "node_modules"); + const agentNodeModules = join(tmp, "agent", "node_modules"); + + // Hoisted: only external deps (no @anthropic-ai — it's internal-only) + mkdirSync(join(hoisted, "yaml"), { recursive: true }); + mkdirSync(pkgRoot, { recursive: true }); + + // Internal: workspace packages + optional dep that wasn't hoisted + mkdirSync(join(internal, "@gsd", "pi-ai"), { recursive: true }); + mkdirSync(join(internal, "@anthropic-ai", "claude-agent-sdk"), { recursive: true }); + + mkdirSync(agentNodeModules, { recursive: true }); + + // Link hoisted entries + for (const entry of readdirSync(hoisted, { withFileTypes: true })) { + if (entry.name === "gsd-pi" || entry.name.startsWith(".")) continue; + symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name)); + } + + // Overlay all non-dotfile internal entries (the fixed logic) + for (const entry of readdirSync(internal, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const link = join(agentNodeModules, entry.name); + try { lstatSync(link); unlinkSync(link); } catch { /* didn't exist */ } + symlinkSync(join(internal, entry.name), link); + } + + // @anthropic-ai must be present — this is what broke in #3564 + assert.ok(existsSync(join(agentNodeModules, "@anthropic-ai")), "@anthropic-ai should resolve from internal"); + assert.ok(existsSync(join(agentNodeModules, "@anthropic-ai", "claude-agent-sdk")), "@anthropic-ai/claude-agent-sdk should resolve"); + + // @gsd still resolves + assert.ok(existsSync(join(agentNodeModules, "@gsd")), "@gsd should resolve"); + + // Hoisted deps still resolve + assert.ok(existsSync(join(agentNodeModules, "yaml")), "yaml should resolve"); +}); + test("hasMissingWorkspaceScopes detects pnpm layout", (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-pnpm-detect-")); t.after(() => rmSync(tmp, { recursive: true, force: true }));