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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-20 10:48:17 -06:00 committed by GitHub
parent fb7b484d10
commit 8f39eefb4b
4 changed files with 44 additions and 10 deletions

View file

@ -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'))
}

View file

@ -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<void> {
const { loadVisualizerData } = await import("./visualizer-data.js");
const { generateHtmlReport } = await import("./export-html.js");
const { writeReportSnapshot } = await import("./reports.js");
const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(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<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
const [decisionsContent, requirementsContent, projectContent] =
await Promise.all([
inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),

View file

@ -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<typeof import("./auto-dashboard.js")>(import.meta.url, "./auto-dashboard.js");
const arg = trimmed.replace(/^widget\s*/, "").trim();
if (arg === "full" || arg === "small" || arg === "min" || arg === "off") {
setWidgetMode(arg);

View file

@ -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<void>;
@ -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<typeof import("./auto.js")>(import.meta.url, "./auto.js")).stopAuto;
await stopAuto(ctx, pi, "Graceful exit");
ctx.shutdown();
},