fix(#2717): write CommonJS marker for cursor/windsurf/codex staged .js hooks (#2846)

* test(#2717): CommonJS marker for cursor/windsurf/codex staged .js hooks

Cursor/windsurf (skipSharedHooksInstall) and codex (!isCodex gate) stage .js
hook scripts via dedicated paths that bypass installSharedHooksBundle — the
only writer of the {"type":"commonjs"} marker. Under a config root declaring
{"type":"module"}, Node loaded those scripts as ESM and every require()
failed with 'require is not defined', silently disabling the runtime's hooks.

Adds regression tests (RED first, fix lands next commit):
- parametrized cursor/windsurf/codex install asserts hooks/package.json exists
  with exactly GSD's marker content;
- end-to-end: a cursor require()-using hook loads under a planted ESM-typed
  config root without the require-is-not-defined error;
- the ensureCommonJsMarker / removeCommonJsMarkerIfGsdOwned contract: GSD
  markers are removed on uninstall, user-authored package.json is never touched.

* fix(#2717): write CommonJS marker for cursor/windsurf/codex staged .js hooks

The {"type":"commonjs"} marker lived only inside installSharedHooksBundle,
which cursor/windsurf (skipSharedHooksInstall) and codex (!isCodex gate) never
reach. Their .js hooks are staged by dedicated paths, so under a config root
declaring {"type":"module"} Node loaded them as ESM and every require()
failed with 'require is not defined', silently disabling those runtimes' hooks.

Decouple the marker write into a shared helper so any code path that stages
.js hooks can ensure it lands in the SAME directory as the scripts:

- src/runtime-hooks-surface.cts: add ensureCommonJsMarker(dir) +
  removeCommonJsMarkerIfGsdOwned(dir) (byte-identical content to
  installSharedHooksBundle's marker; preserves a user-authored package.json on
  both write and uninstall). Call ensureCommonJsMarker(hooksDir) from
  writeCursorHooksJson + writeWindsurfHooksJson; call
  removeCommonJsMarkerIfGsdOwned on their matching remove paths. Export both.
- bin/install.js: call hooksSurface.ensureCommonJsMarker after the codex hook
  copy; call hooksSurface.removeCommonJsMarkerIfGsdOwned in the generic
  hooks-removal loop (safe no-op where no marker exists).

No change to which runtimes receive the shared bundle, the !isCodex gate,
skipSharedHooksInstall, or kimi/kimi-code/cline/copilot/trae/zcode (all
unchanged — audit in the diagnosis). RED @ dbb7d2bb (6 failures: 3 missing
markers + the ESM require error + missing helpers); GREEN pending.

* docs(#2717): changeset fragment (pr:0, backfilled post-PR)

* chore(#2717): regen codex/cursor/windsurf install-tree fixtures + attribution ack

The fix adds hooks/package.json to those three runtimes' install trees (the
new CommonJS marker), so the golden install-tree fixtures gain one path each
(regenerated via npm run gen:install-tree). emitted-attribution (ADR-2719)
flags the 3 emitted hooks/package.json paths under the hooks-built rule;
acknowledge them. Also drops 5 spent ack entries left by now-merged PRs
(#2694 code-review.md/code-review-fix.md, #2695 worker/registry, #2794
review.md) — they are stale on this branch (base already carries them).

* fix(#2717): codex ESM-root behavioral test + hooks-built provenance for package.json

Two review-driven follow-ups on the #2717 fix:
- Adversarial review noted the ESM-root behavioral test covered only cursor;
  refactor it into a helper and add a codex case (the !isCodex-gated path most
  likely to regress, whose marker write lives in bin/install.js). gsd-check-update.js
  require()s at module load, so it surfaces the ESM failure immediately.
- emitted-provenance flagged hooks/package.json as 'attributed source does not
  exist' — the marker is code-derived (a fixed literal emitted by
  ensureCommonJsMarker at install time), not built from a tracked source. Route
  the hooks-built rule's sources/transforms for package.json to the surface
  source file, mirroring the existing .cmd-shim sub-family.

* chore(#2717): drop now-redundant hooks/package.json attribution ack

The hooks-built provenance routing (prior commit) now self-attributes the
emitted hooks/package.json to src/runtime-hooks-surface.cts, which IS in this
diff — so the attribution is self-explaining and the emitted-drift-ack entry
became stale. Delete the (now-empty) ack file per ADR-2719's empty-file rule.

* docs(changeset): backfill #2717 PR number to 2846
This commit is contained in:
Tom Boucher
2026-07-29 22:18:48 -04:00
committed by GitHub
parent b12d4df03b
commit 4f6935e29b
9 changed files with 289 additions and 22 deletions

View File

@@ -199,6 +199,81 @@ function atomicWriteFileSync(target: string, data: string, options: fs.WriteFile
}
}
// ---------------------------------------------------------------------------
// CommonJS package.json marker for staged .js hook scripts (#2717)
//
// Node resolves the nearest package.json walking up from a .js file. When a
// runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any
// parent — declares {"type":"module"}, Node loads GSD's staged CommonJS hook
// scripts as ESM and every require() fails with "require is not defined",
// silently disabling that runtime's lifecycle hooks.
//
// installSharedHooksBundle writes this marker for the 12 runtimes that go
// through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall)
// and codex (the !isCodex gate) stage their .js hooks via the dedicated paths
// below and never reached it. These helpers decouple the marker write from the
// shared bundle so any code path that stages .js hooks can ensure the marker
// lands in the SAME directory as the scripts (#2717).
//
// The marker content is byte-identical to installSharedHooksBundle's
// (bin/install.js installSharedHooksBundle): {"type":"commonjs"}\n.
// ---------------------------------------------------------------------------
/** The exact marker content GSD writes, matching installSharedHooksBundle. */
const COMMONJS_MARKER_CONTENT = '{"type":"commonjs"}\n';
/**
* Ensure a `package.json` forcing CommonJS mode exists in `dir` (the directory
* holding GSD-staged `.js` hook scripts). Idempotent: a no-op if the marker is
* already present with GSD's content. Overwrites only when the file is absent
* or already carries GSD's exact marker — it never clobbers a distinct
* user-authored package.json (it leaves such a file in place; the user owns it).
*
* @param dir - absolute path to the directory holding the staged .js hooks
* @returns `true` if the marker is present after the call (written or already there)
*/
function ensureCommonJsMarker(dir: string): boolean {
const markerPath = path.join(dir, 'package.json');
try {
if (fs.existsSync(markerPath)) {
const existing = fs.readFileSync(markerPath, 'utf8');
// Already GSD's marker (tolerant of trailing-whitespace variants) — done.
if (existing.trim() === '{"type":"commonjs"}') return true;
// A distinct package.json the user owns — do NOT clobber. The hook will
// load as whatever type the user declared; that is the user's choice.
return false;
}
fs.writeFileSync(markerPath, COMMONJS_MARKER_CONTENT);
return true;
} catch {
// Best-effort: a marker write failure must not fail the whole install.
return false;
}
}
/**
* Remove the CommonJS marker from `dir` on uninstall — but ONLY if it carries
* GSD's exact marker content. A user-authored package.json is never deleted.
* Mirrors the kimi uninstall guard in bin/install.js.
*
* @param dir - absolute path to the directory that held the staged .js hooks
* @returns `true` if a GSD-owned marker was removed
*/
function removeCommonJsMarkerIfGsdOwned(dir: string): boolean {
const markerPath = path.join(dir, 'package.json');
try {
if (!fs.existsSync(markerPath)) return false;
const content = fs.readFileSync(markerPath, 'utf8').trim();
if (content === '{"type":"commonjs"}') {
fs.unlinkSync(markerPath);
return true;
}
return false;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// parseTomlValue + findMultilineBasicStringClose
// (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state)
@@ -1191,6 +1266,13 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
}
}
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Cursor sets skipSharedHooksInstall, so it never reaches
// installSharedHooksBundle (the only other writer of this marker); without
// it, a ~/.cursor/package.json declaring {"type":"module"} makes Node load
// these require()-using scripts as ESM and every Cursor hook fails silently.
ensureCommonJsMarker(hooksDir);
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
const commands: Record<string, string | null> = {};
for (const ev of events) {
@@ -1224,6 +1306,11 @@ function removeCursorHooksJson(targetDir: string): { changed: boolean } {
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
// #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
// only if it still carries GSD's exact content (a user-authored
// package.json is never deleted). Best-effort: a failure here must not
// mask the hooks.json removal above.
try { removeCommonJsMarkerIfGsdOwned(path.join(targetDir, 'hooks')); } catch { /* leave it */ }
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
@@ -1392,6 +1479,13 @@ function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWind
}
}
// #2717: write the CommonJS marker into hooks/ alongside the staged .js
// scripts. Windsurf sets skipSharedHooksInstall, so it never reaches
// installSharedHooksBundle (the only other writer of this marker); without
// it, a config-root package.json declaring {"type":"module"} makes Node load
// these require()-using scripts as ESM and the Windsurf hooks fail silently.
ensureCommonJsMarker(hooksDir);
const hookOpts: BuildHookCommandOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
const commands: Record<string, string | null> = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
@@ -1433,6 +1527,10 @@ function removeWindsurfHooksJson(targetDir: string): { changed: boolean } {
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
// #2717: also remove the CommonJS marker GSD wrote into hooks/ — but
// only if it still carries GSD's exact content (a user-authored
// package.json is never deleted). Best-effort.
try { removeCommonJsMarkerIfGsdOwned(path.join(targetDir, 'hooks')); } catch { /* leave it */ }
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
@@ -2352,6 +2450,8 @@ export = {
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
GSD_WINDSURF_HOOK_SCRIPTS,
ensureCommonJsMarker,
removeCommonJsMarkerIfGsdOwned,
GSD_WINDSURF_HOOK_MARKER,
// Copilot