From 7a413bb84f99d142ed426d5e6124fd46b615f4e3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 09:34:41 -0400 Subject: [PATCH] fix(web): resolve compiled .js modules for all subprocess calls under node_modules (#2320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node v24 unconditionally refuses .ts files under node_modules/ — even with --experimental-transform-types. When GSD is installed globally via npm, every web service subprocess that loads a .ts extension module crashes with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Add resolveSubprocessModule() and buildSubprocessPrefixArgs() to ts-subprocess-flags.ts. When packageRoot is under node_modules/ and the compiled dist/*.js file exists, subprocess calls use the compiled JS directly without TS flags or the resolve-ts.mjs loader. Updated all 14 web service files: auto-dashboard, bridge, captures, cleanup, doctor, export, forensics, history, hooks, recovery-diagnostics, settings, skill-health, undo, and visualizer. Fixes #2279 Co-authored-by: Claude Opus 4.6 (1M context) --- .../web-subprocess-module-resolution.test.ts | 157 ++++++++++++++++++ src/web/auto-dashboard-service.ts | 30 ++-- src/web/bridge-service.ts | 27 ++- src/web/captures-service.ts | 36 ++-- src/web/cleanup-service.ts | 36 ++-- src/web/doctor-service.ts | 54 +++--- src/web/export-service.ts | 21 ++- src/web/forensics-service.ts | 21 ++- src/web/history-service.ts | 21 ++- src/web/hooks-service.ts | 21 ++- src/web/recovery-diagnostics-service.ts | 30 ++-- src/web/settings-service.ts | 49 ++++-- src/web/skill-health-service.ts | 21 ++- src/web/ts-subprocess-flags.ts | 74 ++++++++- src/web/undo-service.ts | 42 +++-- src/web/visualizer-service.ts | 21 ++- 16 files changed, 459 insertions(+), 202 deletions(-) create mode 100644 src/tests/web-subprocess-module-resolution.test.ts diff --git a/src/tests/web-subprocess-module-resolution.test.ts b/src/tests/web-subprocess-module-resolution.test.ts new file mode 100644 index 000000000..3c10d8057 --- /dev/null +++ b/src/tests/web-subprocess-module-resolution.test.ts @@ -0,0 +1,157 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { join } from "node:path" + +import { + isUnderNodeModules, + resolveSubprocessModule, +} from "../web/ts-subprocess-flags.ts" + +// --------------------------------------------------------------------------- +// isUnderNodeModules — exported utility +// --------------------------------------------------------------------------- + +test("isUnderNodeModules returns false for paths outside node_modules", () => { + assert.equal(isUnderNodeModules("/home/user/projects/gsd"), false) +}) + +test("isUnderNodeModules returns true for Unix paths under node_modules/", () => { + assert.equal( + isUnderNodeModules("/usr/lib/node_modules/gsd-pi"), + true, + ) +}) + +test("isUnderNodeModules returns true for Windows paths under node_modules/", () => { + assert.equal( + isUnderNodeModules("C:\\Users\\dev\\AppData\\node_modules\\gsd-pi"), + true, + ) +}) + +test("isUnderNodeModules returns false for substring match without trailing slash", () => { + assert.equal( + isUnderNodeModules("/home/user/my_node_modules_backup/gsd"), + false, + ) +}) + +// --------------------------------------------------------------------------- +// resolveSubprocessModule — resolves .ts → dist .js under node_modules +// --------------------------------------------------------------------------- + +test("resolveSubprocessModule returns source .ts path when NOT under node_modules", () => { + const packageRoot = "/home/user/projects/gsd" + const result = resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/workspace-index.ts", + // existsSync not needed — should return src path without checking dist + ) + + assert.deepEqual(result, { + modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"), + useCompiledJs: false, + }) +}) + +test("resolveSubprocessModule returns compiled .js path when under node_modules and dist file exists", () => { + const packageRoot = "/usr/lib/node_modules/gsd-pi" + const distPath = join(packageRoot, "dist", "resources/extensions/gsd/workspace-index.js") + const result = resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/workspace-index.ts", + (p: string) => p === distPath, + ) + + assert.deepEqual(result, { + modulePath: distPath, + useCompiledJs: true, + }) +}) + +test("resolveSubprocessModule falls back to source .ts when under node_modules but dist file missing", () => { + const packageRoot = "/usr/lib/node_modules/gsd-pi" + const result = resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/workspace-index.ts", + () => false, // dist file does not exist + ) + + assert.deepEqual(result, { + modulePath: join(packageRoot, "src", "resources/extensions/gsd/workspace-index.ts"), + useCompiledJs: false, + }) +}) + +test("resolveSubprocessModule handles Windows paths under node_modules", () => { + const packageRoot = "C:\\Users\\dev\\AppData\\node_modules\\gsd-pi" + const distPath = join(packageRoot, "dist", "resources/extensions/gsd/auto.js") + const result = resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/auto.ts", + (p: string) => p === distPath, + ) + + assert.deepEqual(result, { + modulePath: distPath, + useCompiledJs: true, + }) +}) + +test("resolveSubprocessModule strips .ts extension when building dist .js path", () => { + const packageRoot = "/usr/lib/node_modules/gsd-pi" + let checkedPath = "" + resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/doctor.ts", + (p: string) => { checkedPath = p; return true }, + ) + + assert.equal( + checkedPath, + join(packageRoot, "dist", "resources/extensions/gsd/doctor.js"), + "should check for .js file in dist/, not .ts", + ) +}) + +// --------------------------------------------------------------------------- +// Integration: bridge-service subprocess resolution pattern +// --------------------------------------------------------------------------- + +test("bridge-service workspace-index subprocess uses compiled JS when under node_modules (source audit)", async () => { + // Verify bridge-service.ts calls resolveSubprocessModule for workspace-index + const { readFileSync } = await import("node:fs") + const bridgeSource = readFileSync( + join(process.cwd(), "src", "web", "bridge-service.ts"), + "utf-8", + ) + + assert.match( + bridgeSource, + /resolveSubprocessModule/, + "bridge-service.ts must use resolveSubprocessModule to resolve workspace-index path — " + + "hardcoded .ts paths fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node v24 (see #2279)", + ) +}) + +test("all web service files use resolveSubprocessModule instead of hardcoded .ts paths (source audit)", async () => { + const { readFileSync, readdirSync } = await import("node:fs") + + const serviceFiles = readdirSync(join(process.cwd(), "src", "web")) + .filter((f: string) => f.endsWith("-service.ts")) + + for (const file of serviceFiles) { + const source = readFileSync(join(process.cwd(), "src", "web", file), "utf-8") + + // If the service file imports resolveTypeStrippingFlag it spawns subprocesses + // and must also use resolveSubprocessModule + if (source.includes("resolveTypeStrippingFlag")) { + assert.match( + source, + /resolveSubprocessModule/, + `${file} uses resolveTypeStrippingFlag but does not use resolveSubprocessModule — ` + + "subprocess .ts paths will fail under node_modules/ on Node v24 (#2279)", + ) + } + } +}) diff --git a/src/web/auto-dashboard-service.ts b/src/web/auto-dashboard-service.ts index fdce2c0c9..58c62a4ad 100644 --- a/src/web/auto-dashboard-service.ts +++ b/src/web/auto-dashboard-service.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { AutoDashboardData } from "./bridge-service.ts"; -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" const AUTO_DASHBOARD_MAX_BUFFER = 1024 * 1024; const TEST_AUTO_DASHBOARD_MODULE_ENV = "GSD_WEB_TEST_AUTO_DASHBOARD_MODULE"; @@ -32,10 +32,6 @@ function fallbackAutoDashboardData(): AutoDashboardData { }; } -function resolveAutoDashboardModulePath(packageRoot: string, env: NodeJS.ProcessEnv): string { - return env[TEST_AUTO_DASHBOARD_MODULE_ENV] || join(packageRoot, "src", "resources", "extensions", "gsd", "auto.ts"); -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs"); } @@ -55,11 +51,20 @@ export async function collectAuthoritativeAutoDashboardData( const checkExists = options.existsSync ?? existsSync; const resolveTsLoader = resolveTsLoaderPath(packageRoot); - const autoModulePath = resolveAutoDashboardModulePath(packageRoot, env); - if (!checkExists(resolveTsLoader) || !checkExists(autoModulePath)) { + // Use test override if provided; otherwise resolve via resolveSubprocessModule + const testModulePath = env[TEST_AUTO_DASHBOARD_MODULE_ENV]; + const moduleResolution = testModulePath + ? { modulePath: testModulePath, useCompiledJs: false } + : resolveSubprocessModule(packageRoot, "resources/extensions/gsd/auto.ts", checkExists); + const autoModulePath = moduleResolution.modulePath; + + if (!moduleResolution.useCompiledJs && (!checkExists(resolveTsLoader) || !checkExists(autoModulePath))) { throw new Error(`authoritative auto dashboard provider not found; checked=${resolveTsLoader},${autoModulePath}`); } + if (moduleResolution.useCompiledJs && !checkExists(autoModulePath)) { + throw new Error(`authoritative auto dashboard provider not found; checked=${autoModulePath}`); + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -68,14 +73,17 @@ export async function collectAuthoritativeAutoDashboardData( 'process.stdout.write(JSON.stringify(result));', ].join(" "); + const prefixArgs = buildSubprocessPrefixArgs( + packageRoot, + moduleResolution, + pathToFileURL(resolveTsLoader).href, + ); + return await new Promise((resolveResult, reject) => { execFile( options.execPath ?? process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index 32ed1048b..ebac2e8b1 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder"; import type { Readable } from "node:stream"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts"; +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts"; import type { AgentSessionEvent, SessionStateChangeReason } from "../../packages/pi-coding-agent/src/core/agent-session.ts"; import type { @@ -905,12 +905,20 @@ async function loadCachedWorkspaceIndex( async function loadWorkspaceIndexViaChildProcess(basePath: string, packageRoot: string): Promise { const deps = getBridgeDeps(); - const resolveTsLoader = join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs"); - const workspaceModulePath = join(packageRoot, "src", "resources", "extensions", "gsd", "workspace-index.ts"); const checkExists = deps.existsSync ?? existsSync; - if (!checkExists(resolveTsLoader) || !checkExists(workspaceModulePath)) { + const resolveTsLoader = join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs"); + const moduleResolution = resolveSubprocessModule( + packageRoot, + "resources/extensions/gsd/workspace-index.ts", + checkExists, + ); + const workspaceModulePath = moduleResolution.modulePath; + if (!moduleResolution.useCompiledJs && (!checkExists(resolveTsLoader) || !checkExists(workspaceModulePath))) { throw new Error(`workspace index loader not found; checked=${resolveTsLoader},${workspaceModulePath}`); } + if (moduleResolution.useCompiledJs && !checkExists(workspaceModulePath)) { + throw new Error(`workspace index module not found; checked=${workspaceModulePath}`); + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -919,14 +927,17 @@ async function loadWorkspaceIndexViaChildProcess(basePath: string, packageRoot: 'process.stdout.write(JSON.stringify(result));', ].join(' '); + const prefixArgs = buildSubprocessPrefixArgs( + packageRoot, + moduleResolution, + pathToFileURL(resolveTsLoader).href, + ); + return await new Promise((resolveResult, reject) => { execFile( deps.execPath ?? process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/captures-service.ts b/src/web/captures-service.ts index 938cdf396..1f7cb1189 100644 --- a/src/web/captures-service.ts +++ b/src/web/captures-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { CapturesData, CaptureResolveRequest, CaptureResolveResult } from "../../web/lib/knowledge-captures-types.ts" const CAPTURES_MAX_BUFFER = 2 * 1024 * 1024 const CAPTURES_MODULE_ENV = "GSD_CAPTURES_MODULE" -function resolveCapturesModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "captures.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -28,13 +24,17 @@ export async function collectCapturesData(projectCwdOverride?: string): Promise< const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const capturesModulePath = resolveCapturesModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/captures.ts") + const capturesModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath))) { throw new Error( `captures data provider not found; checked=${resolveTsLoader},${capturesModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(capturesModulePath)) { + throw new Error(`captures data provider not found; checked=${capturesModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -46,14 +46,13 @@ export async function collectCapturesData(projectCwdOverride?: string): Promise< 'process.stdout.write(JSON.stringify(result));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], @@ -95,13 +94,17 @@ export async function resolveCaptureAction(request: CaptureResolveRequest, proje const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const capturesModulePath = resolveCapturesModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/captures.ts") + const capturesModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(capturesModulePath))) { throw new Error( `captures data provider not found; checked=${resolveTsLoader},${capturesModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(capturesModulePath)) { + throw new Error(`captures data provider not found; checked=${capturesModulePath}`) + } const safeId = JSON.stringify(request.captureId) const safeClassification = JSON.stringify(request.classification) @@ -115,14 +118,13 @@ export async function resolveCaptureAction(request: CaptureResolveRequest, proje `process.stdout.write(JSON.stringify({ ok: true, captureId: ${safeId} }));`, ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/cleanup-service.ts b/src/web/cleanup-service.ts index a83ba40f3..145201f31 100644 --- a/src/web/cleanup-service.ts +++ b/src/web/cleanup-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { CleanupData, CleanupResult } from "../../web/lib/remaining-command-types.ts" const CLEANUP_MAX_BUFFER = 2 * 1024 * 1024 const CLEANUP_MODULE_ENV = "GSD_CLEANUP_MODULE" -function resolveCleanupModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "native-git-bridge.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -28,13 +24,17 @@ export async function collectCleanupData(projectCwdOverride?: string): Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], @@ -114,13 +113,17 @@ export async function executeCleanup( const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const cleanupModulePath = resolveCleanupModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/native-git-bridge.ts") + const cleanupModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(cleanupModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(cleanupModulePath))) { throw new Error( `cleanup service modules not found; checked=${resolveTsLoader},${cleanupModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(cleanupModulePath)) { + throw new Error(`cleanup service modules not found; checked=${cleanupModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -147,14 +150,13 @@ export async function executeCleanup( 'process.stdout.write(JSON.stringify({ deletedBranches, prunedSnapshots, message }));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/doctor-service.ts b/src/web/doctor-service.ts index 755f155b3..8fac5b272 100644 --- a/src/web/doctor-service.ts +++ b/src/web/doctor-service.ts @@ -4,47 +4,31 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { DoctorReport, DoctorFixResult } from "../../web/lib/diagnostics-types.ts" const DOCTOR_MAX_BUFFER = 2 * 1024 * 1024 const DOCTOR_MODULE_ENV = "GSD_DOCTOR_MODULE" -function resolveDoctorModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "doctor.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } -function validateModulePaths( - resolveTsLoader: string, - doctorModulePath: string, -): void { - if (!existsSync(resolveTsLoader) || !existsSync(doctorModulePath)) { - throw new Error( - `doctor data provider not found; checked=${resolveTsLoader},${doctorModulePath}`, - ) - } -} - function runDoctorChild( packageRoot: string, projectCwd: string, script: string, resolveTsLoader: string, doctorModulePath: string, + moduleResolution: { modulePath: string; useCompiledJs: boolean }, scope?: string, ): Promise { + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) return new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], @@ -78,8 +62,17 @@ export async function collectDoctorData(scope?: string, projectCwdOverride?: str const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const doctorModulePath = resolveDoctorModulePath(packageRoot) - validateModulePaths(resolveTsLoader, doctorModulePath) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts") + const doctorModulePath = moduleResolution.modulePath + + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(doctorModulePath))) { + throw new Error( + `doctor data provider not found; checked=${resolveTsLoader},${doctorModulePath}`, + ) + } + if (moduleResolution.useCompiledJs && !existsSync(doctorModulePath)) { + throw new Error(`doctor data provider not found; checked=${doctorModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -98,7 +91,7 @@ export async function collectDoctorData(scope?: string, projectCwdOverride?: str ].join(" ") const stdout = await runDoctorChild( - packageRoot, projectCwd, script, resolveTsLoader, doctorModulePath, scope, + packageRoot, projectCwd, script, resolveTsLoader, doctorModulePath, moduleResolution, scope, ) try { @@ -119,8 +112,17 @@ export async function applyDoctorFixes(scope?: string, projectCwdOverride?: stri const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const doctorModulePath = resolveDoctorModulePath(packageRoot) - validateModulePaths(resolveTsLoader, doctorModulePath) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts") + const doctorModulePath = moduleResolution.modulePath + + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(doctorModulePath))) { + throw new Error( + `doctor data provider not found; checked=${resolveTsLoader},${doctorModulePath}`, + ) + } + if (moduleResolution.useCompiledJs && !existsSync(doctorModulePath)) { + throw new Error(`doctor data provider not found; checked=${doctorModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -136,7 +138,7 @@ export async function applyDoctorFixes(scope?: string, projectCwdOverride?: stri ].join(" ") const stdout = await runDoctorChild( - packageRoot, projectCwd, script, resolveTsLoader, doctorModulePath, scope, + packageRoot, projectCwd, script, resolveTsLoader, doctorModulePath, moduleResolution, scope, ) try { diff --git a/src/web/export-service.ts b/src/web/export-service.ts index 46794d972..431f31473 100644 --- a/src/web/export-service.ts +++ b/src/web/export-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { ExportResult } from "../../web/lib/remaining-command-types.ts" const EXPORT_MAX_BUFFER = 4 * 1024 * 1024 const EXPORT_MODULE_ENV = "GSD_EXPORT_MODULE" -function resolveExportModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "export.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -31,13 +27,17 @@ export async function collectExportData( const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const exportModulePath = resolveExportModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/export.ts") + const exportModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(exportModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(exportModulePath))) { throw new Error( `export data provider not found; checked=${resolveTsLoader},${exportModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(exportModulePath)) { + throw new Error(`export data provider not found; checked=${exportModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -55,14 +55,13 @@ export async function collectExportData( '}', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/forensics-service.ts b/src/web/forensics-service.ts index 80867429e..e40703055 100644 --- a/src/web/forensics-service.ts +++ b/src/web/forensics-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { ForensicReport } from "../../web/lib/diagnostics-types.ts" const FORENSICS_MAX_BUFFER = 2 * 1024 * 1024 const FORENSICS_MODULE_ENV = "GSD_FORENSICS_MODULE" -function resolveForensicsModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "forensics.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -30,13 +26,17 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const forensicsModulePath = resolveForensicsModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/forensics.ts") + const forensicsModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(forensicsModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(forensicsModulePath))) { throw new Error( `forensics data provider not found; checked=${resolveTsLoader},${forensicsModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(forensicsModulePath)) { + throw new Error(`forensics data provider not found; checked=${forensicsModulePath}`) + } // The child script loads the upstream module, calls buildForensicReport(), // simplifies the output for browser consumption, and writes JSON to stdout. @@ -74,14 +74,13 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise 'process.stdout.write(JSON.stringify(result));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/history-service.ts b/src/web/history-service.ts index c2d2a8685..a2ee75c68 100644 --- a/src/web/history-service.ts +++ b/src/web/history-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { HistoryData } from "../../web/lib/remaining-command-types.ts" const HISTORY_MAX_BUFFER = 2 * 1024 * 1024 const HISTORY_MODULE_ENV = "GSD_HISTORY_MODULE" -function resolveHistoryModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "metrics.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -28,13 +24,17 @@ export async function collectHistoryData(projectCwdOverride?: string): Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/hooks-service.ts b/src/web/hooks-service.ts index bdaaea267..b8142dda4 100644 --- a/src/web/hooks-service.ts +++ b/src/web/hooks-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { HooksData } from "../../web/lib/remaining-command-types.ts" const HOOKS_MAX_BUFFER = 512 * 1024 const HOOKS_MODULE_ENV = "GSD_HOOKS_MODULE" -function resolveHooksModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "post-unit-hooks.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -29,13 +25,17 @@ export async function collectHooksData(projectCwdOverride?: string): Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/recovery-diagnostics-service.ts b/src/web/recovery-diagnostics-service.ts index 2217ea9af..ee5abeb92 100644 --- a/src/web/recovery-diagnostics-service.ts +++ b/src/web/recovery-diagnostics-service.ts @@ -8,7 +8,7 @@ import { collectSelectiveLiveStatePayload, resolveBridgeRuntimeConfig, } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { WorkspaceRecoveryBrowserAction, WorkspaceRecoveryCodeSummary, @@ -360,14 +360,6 @@ function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } -function resolveDoctorModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "doctor.ts") -} - -function resolveSessionForensicsModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "session-forensics.ts") -} - async function collectRecoveryDiagnosticsChildPayload( packageRoot: string, basePath: string, @@ -379,14 +371,21 @@ async function collectRecoveryDiagnosticsChildPayload( const env = options.env ?? process.env const checkExists = options.existsSync ?? existsSync const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const doctorModulePath = resolveDoctorModulePath(packageRoot) - const sessionForensicsModulePath = resolveSessionForensicsModulePath(packageRoot) + const doctorResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/doctor.ts", checkExists) + const forensicsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/session-forensics.ts", checkExists) + const doctorModulePath = doctorResolution.modulePath + const sessionForensicsModulePath = forensicsResolution.modulePath - if (!checkExists(resolveTsLoader) || !checkExists(doctorModulePath) || !checkExists(sessionForensicsModulePath)) { + if (!doctorResolution.useCompiledJs && (!checkExists(resolveTsLoader) || !checkExists(doctorModulePath) || !checkExists(sessionForensicsModulePath))) { throw new Error( `recovery diagnostics providers not found; checked=${resolveTsLoader},${doctorModulePath},${sessionForensicsModulePath}`, ) } + if (doctorResolution.useCompiledJs && (!checkExists(doctorModulePath) || !checkExists(sessionForensicsModulePath))) { + throw new Error( + `recovery diagnostics providers not found; checked=${doctorModulePath},${sessionForensicsModulePath}`, + ) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -468,14 +467,13 @@ async function collectRecoveryDiagnosticsChildPayload( '}));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, doctorResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( options.execPath ?? process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/settings-service.ts b/src/web/settings-service.ts index fec839679..bbca6132d 100644 --- a/src/web/settings-service.ts +++ b/src/web/settings-service.ts @@ -4,15 +4,11 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { SettingsData } from "../../web/lib/settings-types.ts" const SETTINGS_MAX_BUFFER = 2 * 1024 * 1024 -function resolveModulePath(packageRoot: string, moduleName: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", moduleName) -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -31,16 +27,34 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise< const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const prefsPath = resolveModulePath(packageRoot, "preferences.ts") - const routerPath = resolveModulePath(packageRoot, "model-router.ts") - const budgetPath = resolveModulePath(packageRoot, "context-budget.ts") - const historyPath = resolveModulePath(packageRoot, "routing-history.ts") - const metricsPath = resolveModulePath(packageRoot, "metrics.ts") + const prefsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/preferences.ts") + const routerResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/model-router.ts") + const budgetResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/context-budget.ts") + const historyResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/routing-history.ts") + const metricsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/metrics.ts") - const requiredPaths = [resolveTsLoader, prefsPath, routerPath, budgetPath, historyPath, metricsPath] - for (const p of requiredPaths) { - if (!existsSync(p)) { - throw new Error(`settings data provider not found; missing=${p}`) + const prefsPath = prefsResolution.modulePath + const routerPath = routerResolution.modulePath + const budgetPath = budgetResolution.modulePath + const historyPath = historyResolution.modulePath + const metricsPath = metricsResolution.modulePath + + // All modules share the same compiled-vs-source mode (they're all from the same package) + const useCompiledJs = prefsResolution.useCompiledJs + + if (!useCompiledJs) { + const requiredPaths = [resolveTsLoader, prefsPath, routerPath, budgetPath, historyPath, metricsPath] + for (const p of requiredPaths) { + if (!existsSync(p)) { + throw new Error(`settings data provider not found; missing=${p}`) + } + } + } else { + const requiredPaths = [prefsPath, routerPath, budgetPath, historyPath, metricsPath] + for (const p of requiredPaths) { + if (!existsSync(p)) { + throw new Error(`settings data provider not found; missing=${p}`) + } } } @@ -105,14 +119,13 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise< 'process.stdout.write(JSON.stringify({ preferences, routingConfig, budgetAllocation, routingHistory, projectTotals }));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, prefsResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/skill-health-service.ts b/src/web/skill-health-service.ts index 43e40ddd7..60834dc96 100644 --- a/src/web/skill-health-service.ts +++ b/src/web/skill-health-service.ts @@ -4,16 +4,12 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { SkillHealthReport } from "../../web/lib/diagnostics-types.ts" const SKILL_HEALTH_MAX_BUFFER = 2 * 1024 * 1024 const SKILL_HEALTH_MODULE_ENV = "GSD_SKILL_HEALTH_MODULE" -function resolveSkillHealthModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "skill-health.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -27,13 +23,17 @@ export async function collectSkillHealthData(projectCwdOverride?: string): Promi const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const skillHealthModulePath = resolveSkillHealthModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/skill-health.ts") + const skillHealthModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(skillHealthModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(skillHealthModulePath))) { throw new Error( `skill-health data provider not found; checked=${resolveTsLoader},${skillHealthModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(skillHealthModulePath)) { + throw new Error(`skill-health data provider not found; checked=${skillHealthModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -43,14 +43,13 @@ export async function collectSkillHealthData(projectCwdOverride?: string): Promi 'process.stdout.write(JSON.stringify(report));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/ts-subprocess-flags.ts b/src/web/ts-subprocess-flags.ts index 2365274e8..cb9d4977f 100644 --- a/src/web/ts-subprocess-flags.ts +++ b/src/web/ts-subprocess-flags.ts @@ -1,3 +1,6 @@ +import { existsSync as defaultExistsSync } from "node:fs" +import { join } from "node:path" + /** * Returns the correct Node.js type-stripping flag for subprocess spawning. * @@ -23,11 +26,80 @@ export function resolveTypeStrippingFlag(packageRoot: string): string { * Returns true when the given path sits inside a `node_modules/` directory. * Handles both Unix and Windows path separators. */ -function isUnderNodeModules(filePath: string): boolean { +export function isUnderNodeModules(filePath: string): boolean { const normalized = filePath.replace(/\\/g, "/") return normalized.includes("/node_modules/") } +export interface SubprocessModuleResolution { + /** Absolute path to the module file (either src/.ts or dist/.js). */ + modulePath: string + /** When true the module is pre-compiled JS — skip TS flags and loader. */ + useCompiledJs: boolean +} + +/** + * Resolves a subprocess module path, preferring compiled `dist/*.js` when the + * package root is under `node_modules/`. + * + * Node v24 unconditionally refuses `.ts` files under `node_modules/` — even + * with `--experimental-transform-types`. When GSD is installed globally via + * npm, every subprocess that loads a `.ts` extension module crashes with + * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. + * + * The compiled JS files already ship in the npm package (`dist/` is in the + * `files` array in package.json) and are the correct artefacts to use when + * running from a packaged install. + * + * @param packageRoot Absolute path to the GSD package root. + * @param relPath Path relative to `src/`, e.g. + * `"resources/extensions/gsd/workspace-index.ts"`. + * @param checkExists Optional `existsSync` override (for testing). + */ +export function resolveSubprocessModule( + packageRoot: string, + relPath: string, + checkExists: (path: string) => boolean = defaultExistsSync, +): SubprocessModuleResolution { + if (isUnderNodeModules(packageRoot)) { + const jsRelPath = relPath.replace(/\.ts$/, ".js") + const distPath = join(packageRoot, "dist", jsRelPath) + if (checkExists(distPath)) { + return { modulePath: distPath, useCompiledJs: true } + } + } + + return { + modulePath: join(packageRoot, "src", relPath), + useCompiledJs: false, + } +} + +/** + * Builds the Node.js subprocess prefix args for running a GSD extension module. + * + * When the module resolved to compiled JS (`useCompiledJs === true`), returns + * only `["--input-type=module"]` — no TS loader, no TS stripping flag. + * + * When the module is TypeScript source, returns the full prefix: + * `["--import", , , "--input-type=module"]`. + */ +export function buildSubprocessPrefixArgs( + packageRoot: string, + resolution: SubprocessModuleResolution, + tsLoaderHref: string, +): string[] { + if (resolution.useCompiledJs) { + return ["--input-type=module"] + } + return [ + "--import", + tsLoaderHref, + resolveTypeStrippingFlag(packageRoot), + "--input-type=module", + ] +} + /** * Returns true when the running Node version supports * `--experimental-transform-types` (available since Node v22.7.0). diff --git a/src/web/undo-service.ts b/src/web/undo-service.ts index ede0049c3..ad339a359 100644 --- a/src/web/undo-service.ts +++ b/src/web/undo-service.ts @@ -4,21 +4,13 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" import type { UndoInfo, UndoResult } from "../../web/lib/remaining-command-types.ts" const UNDO_MAX_BUFFER = 2 * 1024 * 1024 const UNDO_MODULE_ENV = "GSD_UNDO_MODULE" const PATHS_MODULE_ENV = "GSD_PATHS_MODULE" -function resolveUndoModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "undo.ts") -} - -function resolvePathsModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "paths.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -119,20 +111,30 @@ export async function collectUndoInfo(projectCwdOverride?: string): Promise { const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride) const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const undoModulePath = resolveUndoModulePath(packageRoot) - const pathsModulePath = resolvePathsModulePath(packageRoot) + const undoResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/undo.ts") + const pathsResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/paths.ts") + const undoModulePath = undoResolution.modulePath + const pathsModulePath = pathsResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(undoModulePath) || !existsSync(pathsModulePath)) { + // For subprocess args we use the undo resolution (both modules share the same compiled-vs-source state) + if (!undoResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(undoModulePath) || !existsSync(pathsModulePath))) { throw new Error( `undo service modules not found; checked=${resolveTsLoader},${undoModulePath},${pathsModulePath}`, ) } + if (undoResolution.useCompiledJs && (!existsSync(undoModulePath) || !existsSync(pathsModulePath))) { + throw new Error(`undo service modules not found; checked=${undoModulePath},${pathsModulePath}`) + } const script = [ 'const { pathToFileURL } = await import("node:url");', @@ -151,23 +153,20 @@ export async function executeUndo(projectCwdOverride?: string): Promise 0) {', - ' const { execSync } = await import("node:child_process");', + ' const { execFileSync } = await import("node:child_process");', ' for (const sha of commits.reverse()) {', - ' try { execSync(`git revert --no-commit ${sha}`, { cwd: basePath, stdio: "pipe" }); commitsReverted++; }', - ' catch { try { execSync("git revert --abort", { cwd: basePath, stdio: "pipe" }); } catch {} break; }', + ' try { execFileSync("git", ["revert", "--no-commit", sha], { cwd: basePath, stdio: "pipe" }); commitsReverted++; }', + ' catch { try { execFileSync("git", ["revert", "--abort"], { cwd: basePath, stdio: "pipe" }); } catch {} break; }', ' }', ' }', '}', - // Remove the entry from completed-units.json 'entries.pop();', 'writeFileSync(completedPath, JSON.stringify(entries, null, 2), "utf-8");', 'const results = [`Undone: ${unitType} (${unitId})`];', @@ -177,14 +176,13 @@ export async function executeUndo(projectCwdOverride?: string): Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ], diff --git a/src/web/visualizer-service.ts b/src/web/visualizer-service.ts index d0b255343..93b1fcdd0 100644 --- a/src/web/visualizer-service.ts +++ b/src/web/visualizer-service.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { pathToFileURL } from "node:url" import { resolveBridgeRuntimeConfig } from "./bridge-service.ts" -import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts" +import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts" const VISUALIZER_MAX_BUFFER = 2 * 1024 * 1024 const VISUALIZER_MODULE_ENV = "GSD_VISUALIZER_MODULE" @@ -35,10 +35,6 @@ export interface SerializedVisualizerData { changelog: unknown } -function resolveVisualizerModulePath(packageRoot: string): string { - return join(packageRoot, "src", "resources", "extensions", "gsd", "visualizer-data.ts") -} - function resolveTsLoaderPath(packageRoot: string): string { return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs") } @@ -54,13 +50,17 @@ export async function collectVisualizerData(projectCwdOverride?: string): Promis const { packageRoot, projectCwd } = config const resolveTsLoader = resolveTsLoaderPath(packageRoot) - const visualizerModulePath = resolveVisualizerModulePath(packageRoot) + const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/visualizer-data.ts") + const visualizerModulePath = moduleResolution.modulePath - if (!existsSync(resolveTsLoader) || !existsSync(visualizerModulePath)) { + if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(visualizerModulePath))) { throw new Error( `visualizer data provider not found; checked=${resolveTsLoader},${visualizerModulePath}`, ) } + if (moduleResolution.useCompiledJs && !existsSync(visualizerModulePath)) { + throw new Error(`visualizer data provider not found; checked=${visualizerModulePath}`) + } // The child script loads the upstream module, calls loadVisualizerData(), // converts Map fields to Records, and writes JSON to stdout. @@ -80,14 +80,13 @@ export async function collectVisualizerData(projectCwdOverride?: string): Promis 'process.stdout.write(JSON.stringify(result));', ].join(" ") + const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href) + return await new Promise((resolveResult, reject) => { execFile( process.execPath, [ - "--import", - pathToFileURL(resolveTsLoader).href, - resolveTypeStrippingFlag(packageRoot), - "--input-type=module", + ...prefixArgs, "--eval", script, ],