fix(web): resolve compiled .js modules for all subprocess calls under node_modules (#2320)

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-24 09:34:41 -04:00 committed by GitHub
parent a0c0896a75
commit 7a413bb84f
16 changed files with 459 additions and 202 deletions

View file

@ -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)",
)
}
}
})

View file

@ -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<AutoDashboardData>((resolveResult, reject) => {
execFile(
options.execPath ?? process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<GSDWorkspaceIndex> {
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<GSDWorkspaceIndex>((resolveResult, reject) => {
execFile(
deps.execPath ?? process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<CapturesData>((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<CaptureResolveResult>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<C
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 data provider not found; checked=${resolveTsLoader},${cleanupModulePath}`,
)
}
if (moduleResolution.useCompiledJs && !existsSync(cleanupModulePath)) {
throw new Error(`cleanup data provider not found; checked=${cleanupModulePath}`)
}
const script = [
'const { pathToFileURL } = await import("node:url");',
@ -60,14 +60,13 @@ export async function collectCleanupData(projectCwdOverride?: string): Promise<C
'process.stdout.write(JSON.stringify({ branches: branchList, snapshots: snapshotList }));',
].join(" ")
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
return await new Promise<CleanupData>((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<CleanupResult>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<string> {
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
return new Promise<string>((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 {

View file

@ -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<ExportResult>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<ForensicReport>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<H
const { packageRoot, projectCwd } = config
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
const historyModulePath = resolveHistoryModulePath(packageRoot)
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/metrics.ts")
const historyModulePath = moduleResolution.modulePath
if (!existsSync(resolveTsLoader) || !existsSync(historyModulePath)) {
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(historyModulePath))) {
throw new Error(
`history data provider not found; checked=${resolveTsLoader},${historyModulePath}`,
)
}
if (moduleResolution.useCompiledJs && !existsSync(historyModulePath)) {
throw new Error(`history data provider not found; checked=${historyModulePath}`)
}
const script = [
'const { pathToFileURL } = await import("node:url");',
@ -48,14 +48,13 @@ export async function collectHistoryData(projectCwdOverride?: string): Promise<H
'process.stdout.write(JSON.stringify({ units, totals, byPhase, bySlice, byModel }));',
].join(" ")
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
return await new Promise<HistoryData>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<Hoo
const { packageRoot, projectCwd } = config
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
const hooksModulePath = resolveHooksModulePath(packageRoot)
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/gsd/post-unit-hooks.ts")
const hooksModulePath = moduleResolution.modulePath
if (!existsSync(resolveTsLoader) || !existsSync(hooksModulePath)) {
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(hooksModulePath))) {
throw new Error(
`hooks data provider not found; checked=${resolveTsLoader},${hooksModulePath}`,
)
}
if (moduleResolution.useCompiledJs && !existsSync(hooksModulePath)) {
throw new Error(`hooks data provider not found; checked=${hooksModulePath}`)
}
// getHookStatus() internally calls resolvePostUnitHooks() and resolvePreDispatchHooks()
// from preferences.ts, which read from process.cwd()/.gsd/preferences.md.
@ -49,14 +49,13 @@ export async function collectHooksData(projectCwdOverride?: string): Promise<Hoo
'process.stdout.write(JSON.stringify({ entries, formattedStatus }));',
].join(" ")
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
return await new Promise<HooksData>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<RecoveryDiagnosticsChildPayload>((resolveResult, reject) => {
execFile(
options.execPath ?? process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<SettingsData>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<SkillHealthReport>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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", <loaderHref>, <tsFlag>, "--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).

View file

@ -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<Undo
* Child-process pattern required because undo calls upstream functions that
* modify git state, completed-units.json, and plan files all of which
* use .ts imports that need the resolve-ts.mjs loader.
*
* NOTE: The child script uses execSync for git-revert because the upstream
* undo module already uses it. This is intentionally preserved from the
* original implementation.
*/
export async function executeUndo(projectCwdOverride?: string): Promise<UndoResult> {
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<UndoResu
'const unitType = last.type;',
'const unitId = last.id;',
'const parts = unitId ? unitId.split("/") : [];',
// Uncheck task in plan if execute-task
'let planUpdated = false;',
'if (unitType === "execute-task" && parts.length === 3) { const [mid, sid, tid] = parts; planUpdated = undoMod.uncheckTaskInPlan(basePath, mid, sid, tid); }',
// Find and revert commits
'let commitsReverted = 0;',
'const activityDir = join(gsdDir, "activity");',
'if (existsSync(activityDir)) {',
' const commits = undoMod.findCommitsForUnit(activityDir, unitType, unitId);',
' if (commits.length > 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<UndoResu
'process.stdout.write(JSON.stringify({ success: true, message: results.join("\\n") }));',
].join(" ")
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, undoResolution, pathToFileURL(resolveTsLoader).href)
return await new Promise<UndoResult>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],

View file

@ -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<SerializedVisualizerData>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
...prefixArgs,
"--eval",
script,
],