From 8f39eefb4b311b9544b402f140785d857e7fbb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 20 Mar 2026 10:48:17 -0600 Subject: [PATCH] fix: create node_modules symlink for dynamic import resolution in extensions (#1623) Native ESM import() ignores NODE_PATH and resolves packages by walking up the directory tree. Extension files synced to ~/.gsd/agent/extensions/ have no ancestor node_modules, so imports of @gsd/* packages fail with "Cannot find package" errors during report generation and other dynamic-import paths. Create a symlink ~/.gsd/agent/node_modules -> GSD's node_modules after resource sync so Node's standard resolution finds @gsd/* packages. Also migrate the most critical dynamic imports in auto-loop, exit-command, and commands to use importExtensionModule (jiti-based) as a belt-and-suspenders fix. Closes #1594 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resource-loader.ts | 36 +++++++++++++++++++- src/resources/extensions/gsd/auto-loop.ts | 10 +++--- src/resources/extensions/gsd/commands.ts | 4 +-- src/resources/extensions/gsd/exit-command.ts | 4 +-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d4c0158a9..03dc9acb0 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -1,7 +1,7 @@ import { DefaultResourceLoader } from '@gsd/pi-coding-agent' import { createHash } from 'node:crypto' import { homedir } from 'node:os' -import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { compareSemver } from './update-check.js' @@ -237,6 +237,35 @@ function copyDirRecursive(src: string, dest: string): void { } } +/** + * Creates (or updates) a symlink at agentDir/node_modules pointing to GSD's + * own node_modules directory. + * + * Native ESM `import()` ignores NODE_PATH — it resolves packages by walking + * up the directory tree from the importing file. Extension files synced to + * ~/.gsd/agent/extensions/ have no ancestor node_modules, so imports of + * @gsd/* packages fail. The symlink makes Node's standard resolution find + * them without requiring every call site to use jiti. + */ +function ensureNodeModulesSymlink(agentDir: string): void { + const agentNodeModules = join(agentDir, 'node_modules') + const gsdNodeModules = join(packageRoot, 'node_modules') + + try { + const existing = readlinkSync(agentNodeModules) + if (existing === gsdNodeModules) return // already correct + unlinkSync(agentNodeModules) + } catch { + // readlinkSync throws if path doesn't exist or isn't a symlink — both are fine + } + + try { + symlinkSync(gsdNodeModules, agentNodeModules, 'junction') + } catch { + // Non-fatal — worst case, extensions fall back to NODE_PATH via jiti + } +} + /** * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch. * @@ -284,6 +313,11 @@ export function initResources(agentDir: string): void { // overwrite them (covers extensions, agents, and skills in one walk). makeTreeWritable(agentDir) + // Ensure ~/.gsd/agent/node_modules symlinks to GSD's node_modules so that + // native ESM import() calls from synced extension files can resolve @gsd/* + // packages via ancestor directory lookup. NODE_PATH only applies to CJS/jiti. + ensureNodeModulesSymlink(agentDir) + writeManagedResourceManifest(agentDir) ensureRegistryEntries(join(agentDir, 'extensions')) } diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts index 4bbf9360a..d5c1e49c5 100644 --- a/src/resources/extensions/gsd/auto-loop.ts +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -10,7 +10,7 @@ * session rotation). No queue — stale agent_end events are dropped. */ -import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; +import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent"; import type { AutoSession } from "./auto/session.js"; import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js"; @@ -563,9 +563,9 @@ async function generateMilestoneReport( ctx: ExtensionContext, milestoneId: string, ): Promise { - const { loadVisualizerData } = await import("./visualizer-data.js"); - const { generateHtmlReport } = await import("./export-html.js"); - const { writeReportSnapshot } = await import("./reports.js"); + const { loadVisualizerData } = await importExtensionModule(import.meta.url, "./visualizer-data.js"); + const { generateHtmlReport } = await importExtensionModule(import.meta.url, "./export-html.js"); + const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "./reports.js"); const { basename } = await import("node:path"); const snapData = await loadVisualizerData(s.basePath); @@ -1344,7 +1344,7 @@ export async function autoLoop( s.lastBaselineCharCount = undefined; if (deps.isDbAvailable()) { try { - const { inlineGsdRootFile } = await import("./auto-prompts.js"); + const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js"); const [decisionsContent, requirementsContent, projectContent] = await Promise.all([ inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"), diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index fcc9c6878..df72e2d4e 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -4,7 +4,7 @@ * One command, one wizard. Routes to smart entry or status. */ -import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent"; import type { GSDState } from "./types.js"; import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs"; import { homedir } from "node:os"; @@ -585,7 +585,7 @@ export async function handleGSDCommand( } if (trimmed === "widget" || trimmed.startsWith("widget ")) { - const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("./auto-dashboard.js"); + const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await importExtensionModule(import.meta.url, "./auto-dashboard.js"); const arg = trimmed.replace(/^widget\s*/, "").trim(); if (arg === "full" || arg === "small" || arg === "min" || arg === "off") { setWidgetMode(arg); diff --git a/src/resources/extensions/gsd/exit-command.ts b/src/resources/extensions/gsd/exit-command.ts index 0cb9e0216..6812f0d58 100644 --- a/src/resources/extensions/gsd/exit-command.ts +++ b/src/resources/extensions/gsd/exit-command.ts @@ -1,4 +1,4 @@ -import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent"; type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise; @@ -10,7 +10,7 @@ export function registerExitCommand( description: "Exit GSD gracefully", handler: async (_args: string, ctx: ExtensionCommandContext) => { // Stop auto-mode first so locks and activity state are cleaned up before shutdown. - const stopAuto = deps.stopAuto ?? (await import("./auto.js")).stopAuto; + const stopAuto = deps.stopAuto ?? (await importExtensionModule(import.meta.url, "./auto.js")).stopAuto; await stopAuto(ctx, pi, "Graceful exit"); ctx.shutdown(); },