From 8e6ad0a347b07934e288e3227e9e409c0b2037d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 19 Jun 2026 23:46:52 +0800 Subject: [PATCH 1/7] fix(scripts): launch lefthook bin shim via shell on Windows spawnSync on a .cmd shim returns EINVAL/null status on recent Node (CVE-2024-27980) unless shell:true, which made postinstall fail and blocked every 'pnpm run' on Windows. (cherry picked from commit 65a08f889ff9738ddaceeeb724e6e24f3be4b2ea) --- scripts/install-lefthook.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index be81daa153..9256a9b462 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -6,8 +6,16 @@ import { join } from 'node:path' const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }) if (git.status !== 0) process.exit(0) -const lefthook = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook') +const isWindows = process.platform === 'win32' +const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') if (!existsSync(lefthook)) process.exit(0) -const result = spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) +// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980) +// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns +// `EINVAL` with a null status, which would otherwise fail postinstall. Quote +// the path because a shell re-parses the command line and the path may contain +// spaces. POSIX needs no shell: the extensionless shim is directly executable. +const result = isWindows + ? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true }) + : spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) process.exit(result.status ?? 1) From 1c4bb7008de55e905747b4d193c77a267d1e30f1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 16:41:40 +0800 Subject: [PATCH 2/7] Pin LF working trees via .gitattributes The repo's committed content is already 100% LF (verified: 802/802 text files); until now the working-tree form depended on each contributor's core.autocrlf, and autocrlf=true checkouts produced CRLF working copies that byte-level gates had to tolerate (fence parsing, consistency-record parsing, blob hashing, README splice comparison). eol=lf removes the smudge boundary entirely: attributes override any local autocrlf, so every checkout on every host sees the repo's canonical form. git add --renormalize confirmed a zero-change no-op - no committed blob (including vendor/) is rewritten. The script-side CRLF tolerances remain as defense in depth for editor-introduced CRLF in not-yet-committed files. If a file class ever needs CRLF in the working tree (.bat/.cmd), a per-pattern eol=crlf override keeps the in-repo form LF while smudging those checkouts only. (cherry picked from commit 5d21ebee20391c3d5c1d3812bd3aaa92bc652973) --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..a51c5e7b5e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# The repo's canonical text form is LF, enforced at checkout too: no smudge +# boundary between working tree and repo, so byte-level gates (verify-* +# comparisons, blob hashing, coverage offsets) see one form on every host. +# If a file class ever genuinely needs CRLF in the working tree (.bat/.cmd +# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the +# in-repo form stays LF; CRLF becomes checkout-time presentation only. +* text=auto eol=lf From d42b118fe3e8f06e3f7613a7c3f86fcef00480f8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 16:55:46 +0800 Subject: [PATCH 3/7] Spawn tsc by its JS entry so doc-typecheck runs on Windows execFileSync('node_modules/.bin/tsc') spawns an extensionless shim that is not executable on Windows (the CVE-2024-27980 class the sibling scripts hit); the catch treated the spawn failure as a compile failure with empty diagnostics. The .cmd shim would need shell:true, which concatenates args unescaped - a hazard for the temp project path - so invoke typescript/bin/tsc through the current node instead; identical behavior on every platform. Note this gate had never actually run on this Windows checkout: with the pre-eol=lf CRLF working copy the fence regex matched no ts blocks ('.' does not match \\r), so it reported 'no ts code blocks to check' and exited green. The LF working tree surfaced the spawn bug; with this fix the gate compiles all 21 blocks on Windows. (cherry picked from commit b49993c28e068c2beb411eae1f0c8ac4985aa3ae) --- scripts/doc-typecheck.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index dfcbbf844c..86266f89b2 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -125,7 +125,12 @@ try { }) try { - execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) + // tsc's JS entry via the current node, not the .bin shim: the extensionless + // shim is not spawnable on Windows (the CVE-2024-27980 class the sibling + // scripts hit), and the .cmd variant would need shell:true, which + // concatenates args UNESCAPED — a hazard for the temp project path. The JS + // entry behaves identically on every platform. + execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) } catch (error: unknown) { const failed = error as { stdout?: Buffer; stderr?: Buffer } const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}` From 4cbd722074f1883b9818dc31f235eb76663973f7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 5 Jul 2026 17:04:35 +0800 Subject: [PATCH 4/7] Declare LF and final-newline conventions in .editorconfig Pairs with .gitattributes: eol=lf pins what git produces at checkout; .editorconfig pins what editors write to disk - the one CRLF vector git's filters cannot reach (git never rewrites the working tree, so an editor-written CRLF file would persist with a clean status while the doc gates misbehave on it). insert_final_newline declares the existing one-trailing-newline policy (AGENTS.md, gated by git diff --check) at the editor layer too. (cherry picked from commit 7a09602fd76efff81a0875fb177289004c3dfc9b) --- .editorconfig | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..1c17d31b2c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +# Editor-side declaration of the repo's text conventions. Pairs with +# .gitattributes: that file pins what GIT produces (LF checkouts), this one +# pins what EDITORS write to disk — the one path git's filters cannot reach. +root = true + +[*] +end_of_line = lf +insert_final_newline = true From 65d07a490f9a8cf5ce8a3bbc2936285182600450 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 4 Jul 2026 23:10:37 +0800 Subject: [PATCH 5/7] fix(scripts): handle .cmd bin shims on Windows (CVE-2024-27980) execFileSync on a .cmd shim returns EINVAL on recent Node without shell:true. Same bug class as install-lefthook.mjs. Affected publint-all.ts and verify-node-next-types.ts. (cherry picked from commit 5ae40bee1c840fbbdd197ee15907aa660a343c52) --- scripts/publint-all.ts | 9 ++++++++- scripts/verify-node-next-types.ts | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index bce8c31dd4..722cf4d2ac 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -6,12 +6,18 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' +const isWindows = process.platform === 'win32' // Discover harness packages at packages//; group containers, // examples, and private vendored sources are not package targets. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') +// On Windows recent Node (CVE-2024-27980) refuses to launch .cmd/.bat bin +// shims without shell:true. Use the absolute path to the .cmd shim so the +// subprocess (not a pnpm child — PATH lacks node_modules/.bin) still finds it. +const publintBin = resolve(root, `node_modules/.bin/publint${isWindows ? '.cmd' : ''}`) + type PublintResult = | { path: string; status: 'passed'; stdout: string; stderr: string } | { path: string; status: 'failed'; stdout: string; stderr: string; message: string } @@ -50,10 +56,11 @@ function outputText(value: unknown): string { async function runPublint(path: string): Promise { try { - const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], { + const { stdout, stderr } = await execFileAsync(publintBin, [path], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, + shell: isWindows, }) return { path, status: 'passed', stdout, stderr } } catch (error: unknown) { diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts index 7883a855c3..7ee046c7bb 100644 --- a/scripts/verify-node-next-types.ts +++ b/scripts/verify-node-next-types.ts @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' +const isWindows = process.platform === 'win32' const root = resolve(import.meta.dirname, '..') interface ExportTarget { @@ -143,9 +144,13 @@ try { .join('\n') writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) - execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + // On Windows the bin shim is a .cmd file; recent Node (CVE-2024-27980) + // refuses to launch .cmd/.bat via execFileSync without shell:true. + const tscBin = isWindows ? resolve(root, 'node_modules/.bin/tsc.cmd') : resolve(root, 'node_modules/.bin/tsc') + execFileSync(tscBin, ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { cwd: root, stdio: 'pipe', + shell: isWindows, }) console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) } catch (error: unknown) { From 7b0310cac427ef629a7604be72098ca20ce39320 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:18:11 +0800 Subject: [PATCH 6/7] fix(scripts): run publint/tsc via node JS entry, not a shell .cmd shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell:true space-joins the executable and args UNESCAPED (Node DEP0190), so an absolute .cmd path breaks whenever the repo path contains spaces and `pnpm run hygiene` fails. Invoke publint's (`node_modules/publint/src/cli.js`) and tsc's (`node_modules/typescript/bin/tsc`) JS entry through process.execPath instead — no shell, extension-agnostic, identical on every platform, matching the pattern already used by doc-typecheck.ts. Addresses ds-review-bot on #324. --- scripts/publint-all.ts | 14 +++++++------- scripts/verify-node-next-types.ts | 11 +++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 722cf4d2ac..0911316f18 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -6,17 +6,18 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' -const isWindows = process.platform === 'win32' // Discover harness packages at packages//; group containers, // examples, and private vendored sources are not package targets. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') -// On Windows recent Node (CVE-2024-27980) refuses to launch .cmd/.bat bin -// shims without shell:true. Use the absolute path to the .cmd shim so the -// subprocess (not a pnpm child — PATH lacks node_modules/.bin) still finds it. -const publintBin = resolve(root, `node_modules/.bin/publint${isWindows ? '.cmd' : ''}`) +// Run publint's JS CLI through the current node, not the .bin shim: the +// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd +// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and +// breaks when the repo path contains spaces. The JS entry is identical on every +// platform (`bin` is `./src/cli.js` per publint's package.json). +const publintCli = resolve(root, 'node_modules/publint/src/cli.js') type PublintResult = | { path: string; status: 'passed'; stdout: string; stderr: string } @@ -56,11 +57,10 @@ function outputText(value: unknown): string { async function runPublint(path: string): Promise { try { - const { stdout, stderr } = await execFileAsync(publintBin, [path], { + const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, - shell: isWindows, }) return { path, status: 'passed', stdout, stderr } } catch (error: unknown) { diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts index 7ee046c7bb..3b577a39bc 100644 --- a/scripts/verify-node-next-types.ts +++ b/scripts/verify-node-next-types.ts @@ -10,7 +10,6 @@ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' -const isWindows = process.platform === 'win32' const root = resolve(import.meta.dirname, '..') interface ExportTarget { @@ -144,13 +143,13 @@ try { .join('\n') writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) - // On Windows the bin shim is a .cmd file; recent Node (CVE-2024-27980) - // refuses to launch .cmd/.bat via execFileSync without shell:true. - const tscBin = isWindows ? resolve(root, 'node_modules/.bin/tsc.cmd') : resolve(root, 'node_modules/.bin/tsc') - execFileSync(tscBin, ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + // tsc's JS entry via the current node, not the .bin shim: the extensionless + // shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs + // shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the + // temp tsconfig path. The JS entry behaves identically on every platform. + execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { cwd: root, stdio: 'pipe', - shell: isWindows, }) console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) } catch (error: unknown) { From e6e587b97d82db3cf2da8d09413863a66a0798e7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:27:10 +0800 Subject: [PATCH 7/7] ci: add a native-Windows build lane (install + build) Runs `pnpm install` + `pnpm run build` (tsc -b + tsdown) on windows-2025, and is listed in all-checks-passed `needs` so a Windows build regression cannot land silently. Windows path/shell support is still partial, so this lane covers the build surface only; tests and gates are not run here yet. --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e45d6698e5..884a0417e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,29 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest + # Windows build lane: install + `pnpm run build` (tsc -b + tsdown) on native + # Windows. Windows path/shell support is still partial, so this lane covers + # the build surface only — tests and gates are not run here yet. Wired into + # all-checks-passed so a native-Windows build regression cannot land silently. + windows-build: + runs-on: windows-2025 + name: windows / build + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Build (tsc -b + tsdown) + run: pnpm run build + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every other job in THIS workflow must be listed in @@ -171,7 +194,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk] + needs: [node-24, node-compat, python-sdk, windows-build] if: always() steps: - name: Fail if any needed job did not succeed