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:
parent
fb7b484d10
commit
8f39eefb4b
4 changed files with 44 additions and 10 deletions
|
|
@ -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'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue