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>
157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
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)",
|
|
)
|
|
}
|
|
}
|
|
})
|