From ca3c55248f11436681123d3f14794411c73a2280 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 12:20:23 -0400 Subject: [PATCH 1/7] build(nix): distrust compromised eMudhra CAs via Node 24.22.1 and Bun 1.4.4 On 2026-10-19 the private keys of eight eMudhra (emSign) intermediate CAs were found to be compromised. Root stores have shipped updates, but Node and Bun carry their own root stores, so the nix-darwin `security.pki.caCertificateBlacklist` fix never reaches them. The pinned nixpkgs still ships Node 24.14.1 and Bun 1.3.13, and the repo pinned Bun 1.4.2; all three still connect to the test site that chains through a compromised intermediate. Dev shell / Nix package: - Add nix/nodejs-bin.nix (official nodejs.org 24.22.1 tarball, darwin-only) and nix/emudhra-overlay.nix, which substitutes that node and Bun 1.4.4 (official GitHub release zip) for `nodejs*`/`bun` on macOS. Linux keeps nixpkgs' packages untouched. - The overlay gates each replacement on `lib.versionOlder` against the pinned nixpkgs version, so it retires itself once `nix flake update` brings nodejs_24 >= 24.22.1 and bun >= 1.4.4. Attribute names never depend on `prev`, and the node gate reads `prev.nodejs-slim_24` because `nodejs_24` is a symlinkJoin that resolves `version` through `final`. - Wire the overlay into flake.nix's single `import nixpkgs` call. Bun pin: - Bump `packageManager`, the `bun` dependency, and `@types/bun` (1.4.3, the latest) in both package.json files; refresh bun.lock and nix/bun.lock.nix; update nix/package.nix's bunCompilerArchives to the @oven/bun-*@1.4.4 integrity hashes (x64 uses the -baseline archives, as since #1214); bump oven-sh/setup-bun pins and CONTRIBUTING. - bunfig's `minimumReleaseAge` (7 days) blocks resolving a same-day Bun release, so the lockfile was resolved once with `--minimum-release-age=0`; `bun install --frozen-lockfile` (what CI runs) passes under the default policy because only new resolutions are gated. Removal: once nixpkgs' nodejs_24 >= 24.22.1 and bun >= 1.4.4, delete nix/emudhra-overlay.nix, nix/nodejs-bin.nix, and the `overlays` line in flake.nix. Keep the bun@1.4.4 pin. Refs: - https://blog.cloudflare.com/emudhra-certificate-compromise/ - https://github.com/nodejs/node/releases/tag/v24.22.1 - https://github.com/oven-sh/bun/releases/tag/bun-v1.4.4 --- .changeset/emudhra-bun-1-4-4-runtime.md | 5 ++ .github/workflows/benchmarks.yml | 2 +- .github/workflows/ci.yml | 12 +-- .github/workflows/install-vm.yml | 2 +- .github/workflows/pr-ci.yml | 10 +-- .github/workflows/release-prebuilt-npm.yml | 12 +-- .github/workflows/website.yml | 2 +- CONTRIBUTING.md | 2 +- bun.lock | 38 ++++----- flake.nix | 3 + nix/bun.lock.nix | 90 +++++++++++----------- nix/emudhra-overlay.nix | 64 +++++++++++++++ nix/nodejs-bin.nix | 78 +++++++++++++++++++ nix/package.nix | 8 +- package.json | 6 +- packages/hunk/package.json | 6 +- 16 files changed, 245 insertions(+), 95 deletions(-) create mode 100644 .changeset/emudhra-bun-1-4-4-runtime.md create mode 100644 nix/emudhra-overlay.nix create mode 100644 nix/nodejs-bin.nix diff --git a/.changeset/emudhra-bun-1-4-4-runtime.md b/.changeset/emudhra-bun-1-4-4-runtime.md new file mode 100644 index 000000000..3d6d4fda6 --- /dev/null +++ b/.changeset/emudhra-bun-1-4-4-runtime.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Upgrade the bundled Bun runtime to 1.4.4 so Hunk's own HTTPS requests (GitHub review via `hunk gh`, extension fetches) reject certificates issued by the compromised eMudhra intermediate CAs. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 8f3f3f5e6..f844f649e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b883ca58..111c31f08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -101,7 +101,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -126,7 +126,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -151,7 +151,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -204,7 +204,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -240,7 +240,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/install-vm.yml b/.github/workflows/install-vm.yml index 5b2ff8e42..e8ffa602c 100644 --- a/.github/workflows/install-vm.yml +++ b/.github/workflows/install-vm.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install ordinary development dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 4f776feb8..fe3a58032 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Verify release-note changeset env: @@ -64,7 +64,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -117,7 +117,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -142,7 +142,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -247,7 +247,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index 3537b0787..665a8810e 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Resolve npm dist-tag and GitHub latest status id: resolve @@ -87,7 +87,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -155,7 +155,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install dependencies run: bun install --frozen-lockfile @@ -195,7 +195,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -255,7 +255,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -302,7 +302,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Download platform artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 7e00075ae..a5a85a6cb 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install repository dependencies run: bun install --frozen-lockfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c943c3794..c77c39ae4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Keep the change focused on one user problem. Before adding another helper, state Requirements: -- Bun 1.4.2+ +- Bun 1.4.4+ - Node.js 22+ for npm package verification and release tasks - Git - macOS, Linux, or Windows diff --git a/bun.lock b/bun.lock index a9b397601..968a689e4 100644 --- a/bun.lock +++ b/bun.lock @@ -19,10 +19,10 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", - "bun": "^1.4.2", + "bun": "^1.4.4", "dependency-cruiser": "18.2.0", "diff": "^8.0.3", "ghostty-opentui": "^1.5.0", @@ -48,7 +48,7 @@ }, "dependencies": { "acorn": "^8.19.0", - "bun": "^1.4.2", + "bun": "^1.4.4", "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", @@ -64,7 +64,7 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/eslint-scope": "^8.4.0", "@types/react": "^19.2.14", "react": "^19.2.4", @@ -212,29 +212,29 @@ "@opentui/react": ["@opentui/react@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zW/hxwc8sJAILGwB5eTC4i48ouGXf0V4lrxmEOk3JIE6JkYVW1TZh1E2pIempqsezgW1sckTIIDudFON8bJ2Mw=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-oKGcxLsvh4oHjAo28nPm8c/xN0o/OmGyj1IHYc94vprkWsW2hNg2keEZt5f40cI5Hvu4jDlSRTR0UNpv1UIAxw=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-klvA9FlwLxZj3JYIOPQA4bbnHIC3uas8kBE4GaGGkeVgAQP/k8hAzYkOqkSaqHBDbxLrTyFEQArEAevJq6NJzg=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sF1xS/scbyN94P2d0eTiarO6JHxrIvhZNT9hnWjQXW5nwKRtfuP+UYnIMNyvW1BQPcZFP/cvfLBNUIe1ea4zPA=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZTVtpf9zmHSBVPX1sBwe5agGb/rN34yPGg/Hby8e2Ax2uCUA2URZKlZH7qLntK7qaFJCMXh9v0GWNYN2zINz1w=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.4", "", { "os": "android", "cpu": "arm64" }, "sha512-5lvn3at5vRC0GxvmiqEiV2/e23tnRi0q5pzR40yfDvDA3OW7PSkt4chF1nA9vnAJhro4/l+ol1FmNP6D3cf/oA=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-QFd0P+mHJxbZFbUla4MgiE5OwxysHaUIYxL2pPNkpWtaI7v4ShOpr8OhBmsuyB0xn5rv/ZJnPsj2y8+bB9+g/A=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-CHbbkmHVM28T1GFq+6GL4Ao4ujwwYwj1CxFFwEFzLanPfNsId9AhbIP0Q4reWuWBkGimUkoQygrK7Rgjtj3vog=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.4", "", { "os": "android", "cpu": "x64" }, "sha512-3dMicj9C1DhaIfDkwt/9GtpU0W+q+GazbG9OIlEyL3Q1BBEz2e6ZRz/JiO8D/ECC0caXAqvnpFudehcLlgIwWQ=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lEEjNOJJrbGZu2NVcPI4uXdILcpspezWkA8R/Jr0RCGhM6zQd0Y7JBIIKNAmbX59EeWpeEz7Q78iLbpcAUypUA=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-n0ep5ZRPCZY1+dqFJpnnRUZB1wy4GYU6At8RF+pyzySCLjkN9R4rFh2PtBHAgF/lg1vOl3/CeER3pDM+p40ATA=="], - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-a3LNctBVO8PjePsFZfwDleF2JXYf2BfQ1u/PLRnBDLnC2Riml+4+LgJpKj4xRGhB92KweGyQI5PXaTkP9qypMQ=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.142.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ=="], @@ -422,7 +422,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], @@ -464,11 +464,11 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], + "bun": ["bun@1.4.4", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.4", "@oven/bun-darwin-x64": "1.4.4", "@oven/bun-freebsd-aarch64": "1.4.4", "@oven/bun-freebsd-x64": "1.4.4", "@oven/bun-linux-aarch64": "1.4.4", "@oven/bun-linux-aarch64-android": "1.4.4", "@oven/bun-linux-aarch64-musl": "1.4.4", "@oven/bun-linux-x64": "1.4.4", "@oven/bun-linux-x64-android": "1.4.4", "@oven/bun-linux-x64-musl": "1.4.4", "@oven/bun-windows-aarch64": "1.4.4", "@oven/bun-windows-x64": "1.4.4" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-hj0ASdG6PNjLZGFAXktWb3Uaj/dcv/1wCsIttoEXADNIQBsGjTr+5uLKYbrDkuc7NT0Tkj4yPIqJI2jDxXdvVg=="], "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], diff --git a/flake.nix b/flake.nix index effe508a5..173ad9f0d 100644 --- a/flake.nix +++ b/flake.nix @@ -38,6 +38,9 @@ system: let pkgs = import nixpkgs { inherit system; + # TEMPORARY: eMudhra root-store workaround for the dev shell's node and + # bun on macOS; self-retiring, see nix/emudhra-overlay.nix. + overlays = [(import ./nix/emudhra-overlay.nix)]; }; hunk = pkgs.callPackage ./nix/package.nix { bun2nix = bun2nix.packages.${system}.default; diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 07b9ab861..c4688a971 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -147,53 +147,53 @@ url = "https://registry.npmjs.org/@opentui/react/-/react-0.5.6.tgz"; hash = "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="; }; - "@oven/bun-darwin-aarch64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.2.tgz"; - hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; + "@oven/bun-darwin-aarch64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.4.tgz"; + hash = "sha512-zW/hxwc8sJAILGwB5eTC4i48ouGXf0V4lrxmEOk3JIE6JkYVW1TZh1E2pIempqsezgW1sckTIIDudFON8bJ2Mw=="; }; - "@oven/bun-darwin-x64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.2.tgz"; - hash = "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="; + "@oven/bun-darwin-x64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.4.tgz"; + hash = "sha512-oKGcxLsvh4oHjAo28nPm8c/xN0o/OmGyj1IHYc94vprkWsW2hNg2keEZt5f40cI5Hvu4jDlSRTR0UNpv1UIAxw=="; }; - "@oven/bun-freebsd-aarch64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.2.tgz"; - hash = "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="; + "@oven/bun-freebsd-aarch64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.4.tgz"; + hash = "sha512-klvA9FlwLxZj3JYIOPQA4bbnHIC3uas8kBE4GaGGkeVgAQP/k8hAzYkOqkSaqHBDbxLrTyFEQArEAevJq6NJzg=="; }; - "@oven/bun-freebsd-x64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.2.tgz"; - hash = "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="; + "@oven/bun-freebsd-x64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.4.tgz"; + hash = "sha512-sF1xS/scbyN94P2d0eTiarO6JHxrIvhZNT9hnWjQXW5nwKRtfuP+UYnIMNyvW1BQPcZFP/cvfLBNUIe1ea4zPA=="; }; - "@oven/bun-linux-aarch64-android@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.2.tgz"; - hash = "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="; + "@oven/bun-linux-aarch64-android@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.4.tgz"; + hash = "sha512-5lvn3at5vRC0GxvmiqEiV2/e23tnRi0q5pzR40yfDvDA3OW7PSkt4chF1nA9vnAJhro4/l+ol1FmNP6D3cf/oA=="; }; - "@oven/bun-linux-aarch64-musl@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.2.tgz"; - hash = "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="; + "@oven/bun-linux-aarch64-musl@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.4.tgz"; + hash = "sha512-QFd0P+mHJxbZFbUla4MgiE5OwxysHaUIYxL2pPNkpWtaI7v4ShOpr8OhBmsuyB0xn5rv/ZJnPsj2y8+bB9+g/A=="; }; - "@oven/bun-linux-aarch64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.2.tgz"; - hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; + "@oven/bun-linux-aarch64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.4.tgz"; + hash = "sha512-ZTVtpf9zmHSBVPX1sBwe5agGb/rN34yPGg/Hby8e2Ax2uCUA2URZKlZH7qLntK7qaFJCMXh9v0GWNYN2zINz1w=="; }; - "@oven/bun-linux-x64-android@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.2.tgz"; - hash = "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="; + "@oven/bun-linux-x64-android@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.4.tgz"; + hash = "sha512-3dMicj9C1DhaIfDkwt/9GtpU0W+q+GazbG9OIlEyL3Q1BBEz2e6ZRz/JiO8D/ECC0caXAqvnpFudehcLlgIwWQ=="; }; - "@oven/bun-linux-x64-musl@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.2.tgz"; - hash = "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="; + "@oven/bun-linux-x64-musl@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.4.tgz"; + hash = "sha512-lEEjNOJJrbGZu2NVcPI4uXdILcpspezWkA8R/Jr0RCGhM6zQd0Y7JBIIKNAmbX59EeWpeEz7Q78iLbpcAUypUA=="; }; - "@oven/bun-linux-x64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.2.tgz"; - hash = "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="; + "@oven/bun-linux-x64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.4.tgz"; + hash = "sha512-CHbbkmHVM28T1GFq+6GL4Ao4ujwwYwj1CxFFwEFzLanPfNsId9AhbIP0Q4reWuWBkGimUkoQygrK7Rgjtj3vog=="; }; - "@oven/bun-windows-aarch64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.2.tgz"; - hash = "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="; + "@oven/bun-windows-aarch64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.4.tgz"; + hash = "sha512-n0ep5ZRPCZY1+dqFJpnnRUZB1wy4GYU6At8RF+pyzySCLjkN9R4rFh2PtBHAgF/lg1vOl3/CeER3pDM+p40ATA=="; }; - "@oven/bun-windows-x64@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.2.tgz"; - hash = "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="; + "@oven/bun-windows-x64@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.4.tgz"; + hash = "sha512-a3LNctBVO8PjePsFZfwDleF2JXYf2BfQ1u/PLRnBDLnC2Riml+4+LgJpKj4xRGhB92KweGyQI5PXaTkP9qypMQ=="; }; "@oxc-parser/binding-android-arm-eabi@0.142.0" = fetchurl { url = "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz"; @@ -567,9 +567,9 @@ url = "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz"; hash = "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="; }; - "@types/bun@1.4.1" = fetchurl { - url = "https://registry.npmjs.org/@types/bun/-/bun-1.4.1.tgz"; - hash = "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="; + "@types/bun@1.4.3" = fetchurl { + url = "https://registry.npmjs.org/@types/bun/-/bun-1.4.3.tgz"; + hash = "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="; }; "@types/eslint-scope@8.4.0" = fetchurl { url = "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-8.4.0.tgz"; @@ -667,13 +667,13 @@ url = "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz"; hash = "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="; }; - "bun-types@1.4.1" = fetchurl { - url = "https://registry.npmjs.org/bun-types/-/bun-types-1.4.1.tgz"; - hash = "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="; + "bun-types@1.4.3" = fetchurl { + url = "https://registry.npmjs.org/bun-types/-/bun-types-1.4.3.tgz"; + hash = "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="; }; - "bun@1.4.2" = fetchurl { - url = "https://registry.npmjs.org/bun/-/bun-1.4.2.tgz"; - hash = "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="; + "bun@1.4.4" = fetchurl { + url = "https://registry.npmjs.org/bun/-/bun-1.4.4.tgz"; + hash = "sha512-hj0ASdG6PNjLZGFAXktWb3Uaj/dcv/1wCsIttoEXADNIQBsGjTr+5uLKYbrDkuc7NT0Tkj4yPIqJI2jDxXdvVg=="; }; "ccount@2.0.1" = fetchurl { url = "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"; diff --git a/nix/emudhra-overlay.nix b/nix/emudhra-overlay.nix new file mode 100644 index 000000000..7702d1e5f --- /dev/null +++ b/nix/emudhra-overlay.nix @@ -0,0 +1,64 @@ +# Swaps the Nix dev shell's Node and Bun for the first releases that distrust the +# compromised eMudhra (emSign) intermediates, on macOS only. +# +# TEMPORARY eMudhra workaround. Node and Bun carry their own root stores, so the +# nix-darwin `security.pki.caCertificateBlacklist` fix does not reach them; the +# first upstream releases whose stores reject the compromised chain are +# node 24.22.1 (NSS 3.131.1 roots + blocked-SPKI check, 2026-10-20) +# bun 1.4.4 (roots updated to NSS 3.131.1, 2026-10-22) +# while the flake's nixpkgs pin still carries older builds. Linux shells keep +# nixpkgs' packages untouched (their trust comes from the system store). +# +# Invariants: +# - Self-retiring: each replacement applies only while the pinned nixpkgs version +# is older than the fixed release, so `nix flake update` turns it off on its own. +# - The attribute *names* this overlay defines never depend on `prev`; the gates +# live in the values. Conditional names would recurse through the fixpoint. +# - Version gates read `prev.nodejs-slim_24`, not `prev.nodejs_24`: at this +# nixpkgs `nodejs_24` is a symlinkJoin whose `version` resolves through `final`, +# which would recurse back into this overlay. +# +# Remove when nixpkgs' `nodejs_24` is >= 24.22.1 and `bun` is >= 1.4.4: delete +# this file, `nix/nodejs-bin.nix`, and the `overlays` line in `flake.nix`. +# Check with: +# curl -s https://raw.githubusercontent.com/NixOS/nixpkgs/nixpkgs-unstable/pkgs/development/web/nodejs/v24.nix | grep version +# curl -s https://raw.githubusercontent.com/NixOS/nixpkgs/nixpkgs-unstable/pkgs/by-name/bu/bun/package.nix | grep version +final: prev: +let + inherit (prev.stdenv.hostPlatform) system isDarwin; + wantNode = prev.lib.versionOlder (prev.nodejs-slim_24 or prev.nodejs_24).version "24.22.1"; + wantBun = prev.lib.versionOlder prev.bun.version "1.4.4"; + + # Official nodejs.org tarball; see the header of nodejs-bin.nix. + nodejsBin = import ./nodejs-bin.nix { pkgs = prev; }; + + # nixpkgs' darwin bun is already "unzip the official GitHub release asset", so + # only version and src move; the install_name_tool/codesign/completion hooks are + # reused as-is. Hashes come from SHASUMS256.txt on the bun-v1.4.4 release. + bunVersion = "1.4.4"; + bunSrc = { + "aarch64-darwin" = prev.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-darwin-aarch64.zip"; + hash = "sha256-wkDX8My5DCY6hJ/NsJt+5iYw2SE0jtaq/ahrnVd7xRA="; + }; + "x86_64-darwin" = prev.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-darwin-x64-baseline.zip"; + hash = "sha256-1KFVrfv9CRVes7oFyeOlRPOSl9emhhbTx3WfZ6tcR9w="; + }; + }; + bunBin = prev.bun.overrideAttrs (_: { + version = bunVersion; + src = bunSrc.${system} or (throw "emudhra-overlay.nix: no bun ${bunVersion} hash for ${system}"); + }); + + # Both the darwin gate and the version gate are evaluated lazily inside each + # value, so non-darwin systems never force the darwin-only derivations. + pick = want: replacement: orig: if isDarwin && want then replacement else orig; +in +{ + nodejs = pick wantNode nodejsBin prev.nodejs; + nodejs_24 = pick wantNode nodejsBin prev.nodejs_24; + nodejs-slim = pick wantNode nodejsBin prev.nodejs-slim; + nodejs-slim_24 = pick wantNode nodejsBin prev.nodejs-slim_24; + bun = pick wantBun bunBin prev.bun; +} diff --git a/nix/nodejs-bin.nix b/nix/nodejs-bin.nix new file mode 100644 index 000000000..d298b058f --- /dev/null +++ b/nix/nodejs-bin.nix @@ -0,0 +1,78 @@ +# Packages the official prebuilt Node.js tarball from nodejs.org for macOS. +# +# TEMPORARY eMudhra workaround, darwin-only. Node ships its own root store, so the +# nix-darwin `security.pki.caCertificateBlacklist` fix does not reach it. Node +# 24.22.1 (2026-10-20) is the first release whose root store (NSS 3.131.1 plus the +# blocked-SPKI check) rejects the compromised eMudhra intermediates; the flake's +# nixpkgs pin still carries 24.21.0. `nix/emudhra-overlay.nix` substitutes this +# derivation for `nodejs`/`nodejs_24` only while that pin is older than 24.22.1, +# so the file retires itself once `nix flake update` catches up. +# +# Remove it once nixpkgs' `nodejs_24` is >= 24.22.1 (24.22.1 merged into nixpkgs +# `staging` on 2026-10-21, NixOS/nixpkgs#576777): delete this file, +# `nix/emudhra-overlay.nix`, and the `overlays` line in `flake.nix`. Check with: +# curl -s https://raw.githubusercontent.com/NixOS/nixpkgs/nixpkgs-unstable/pkgs/development/web/nodejs/v24.nix | grep version +# +# Bumping: change `version`, then copy the darwin sha256 lines from +# https://nodejs.org/dist/v/SHASUMS256.txt +{ pkgs }: +let + version = "24.22.1"; + inherit (pkgs.stdenv.hostPlatform) system; + arch = + { + "aarch64-darwin" = "arm64"; + "x86_64-darwin" = "x64"; + } + .${system} or (throw "nodejs-bin.nix: no prebuilt Node.js tarball mapping for ${system}"); + sha256 = + { + "aarch64-darwin" = "89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"; + "x86_64-darwin" = "d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"; + } + .${system}; +in +pkgs.stdenvNoCC.mkDerivation { + pname = "nodejs-bin"; + inherit version; + + src = pkgs.fetchurl { + url = "https://nodejs.org/dist/v${version}/node-v${version}-darwin-${arch}.tar.gz"; + inherit sha256; + }; + + dontConfigure = true; + dontBuild = true; + # Prebuilt Mach-O binaries; nothing to strip or fix up. + dontStrip = true; + dontFixup = true; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -R bin include lib share $out/ + # npm/npx/corepack ship with `#!/usr/bin/env node`; pin them to this node so + # they work regardless of what else is on PATH. + export PATH=$out/bin:$PATH + patchShebangs $out/lib/node_modules/npm/bin $out/lib/node_modules/corepack/dist + runHook postInstall + ''; + + passthru = { + # Mirror the attributes nixpkgs' nodejs exposes that callers commonly read. + inherit version; + python = pkgs.python3; + }; + + meta = with pkgs.lib; { + description = "Node.js ${version} (official prebuilt binary)"; + homepage = "https://nodejs.org"; + license = licenses.mit; + sourceProvenance = [ sourceTypes.binaryNativeCode ]; + platforms = [ + "aarch64-darwin" + "x86_64-darwin" + ]; + mainProgram = "node"; + }; +} diff --git a/nix/package.nix b/nix/package.nix index a61f87d97..627fadc9a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -17,19 +17,19 @@ bunCompilerArchives = { "aarch64-darwin" = fetchurl { url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-${bunVersion}.tgz"; - hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; + hash = "sha512-zW/hxwc8sJAILGwB5eTC4i48ouGXf0V4lrxmEOk3JIE6JkYVW1TZh1E2pIempqsezgW1sckTIIDudFON8bJ2Mw=="; }; "x86_64-darwin" = fetchurl { url = "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-${bunVersion}.tgz"; - hash = "sha512-lVDbk/HkN+RP8QKXBe0aou7EifA2PC9+Z4cvy2VDpmR1ONdDm8e2h/HsU7ABq7mvVs/76iq5H86nMvAhwJDadQ=="; + hash = "sha512-uzSXckTnNNS7Un1ihiM31R7P5iKndZ65t6Zj3iI72SrfoV2uI97MKM8z1qer2oRHyqXHLUIJTIhj+qT5UeEjrw=="; }; "aarch64-linux" = fetchurl { url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-${bunVersion}.tgz"; - hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; + hash = "sha512-ZTVtpf9zmHSBVPX1sBwe5agGb/rN34yPGg/Hby8e2Ax2uCUA2URZKlZH7qLntK7qaFJCMXh9v0GWNYN2zINz1w=="; }; "x86_64-linux" = fetchurl { url = "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-${bunVersion}.tgz"; - hash = "sha512-OKKzX/SOVIUVARCzTg+2r4Rcc4fPyWnQmBsd3K08buxeu7DAof4iwW9zSb6Lt1NhTCu4Uhi8k9UeI6f7GEE4Zg=="; + hash = "sha512-FBU6T21NT+i+rESbi0Z8A8G5xN51c+WZ0v6pIjI6szSzOlqVNv64nf7FPnwNIyxKD1M7Wg5cEobLC/QbzCeLtA=="; }; }; bunCompilerArchive = bunCompilerArchives.${stdenv.hostPlatform.system}; diff --git a/package.json b/package.json index 75d787c63..9fddb8438 100644 --- a/package.json +++ b/package.json @@ -94,10 +94,10 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", - "bun": "^1.4.2", + "bun": "^1.4.4", "dependency-cruiser": "18.2.0", "diff": "^8.0.3", "ghostty-opentui": "^1.5.0", @@ -122,7 +122,7 @@ "engines": { "node": ">=22" }, - "packageManager": "bun@1.4.2", + "packageManager": "bun@1.4.4", "pi": { "skills": [ "./skills", diff --git a/packages/hunk/package.json b/packages/hunk/package.json index b56554bfc..b24033fc3 100644 --- a/packages/hunk/package.json +++ b/packages/hunk/package.json @@ -51,7 +51,7 @@ }, "dependencies": { "acorn": "^8.19.0", - "bun": "^1.4.2", + "bun": "^1.4.4", "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", @@ -67,7 +67,7 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.4.1", + "@types/bun": "1.4.3", "@types/eslint-scope": "^8.4.0", "@types/react": "^19.2.14", "react": "^19.2.4", @@ -87,7 +87,7 @@ "engines": { "node": ">=22" }, - "packageManager": "bun@1.4.2", + "packageManager": "bun@1.4.4", "pi": { "skills": [ "./skills" From 6010ad98af53ec1b70f23963365e36450ebf071f Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 16:17:27 -0400 Subject: [PATCH 2/7] fix(pager): read the controlling terminal without blocking so quitting exits on Bun 1.4.3+ With Bun 1.4.3 and later, `git diff | hunk pager` (and any run whose stdin is not a terminal) no longer exited on `q`: the terminal looked stuck until the user pressed Enter. CI caught it on the Bun 1.4.4 bump; `main` on Bun 1.4.2 was unaffected. Bun implements `tty.ReadStream(fd)` as `fs.ReadStream`, so every read on the `/dev/tty` descriptor Hunk opened for keyboard input is a blocking `read(2)` on a worker thread, and `destroy()` waits for that read to return. The app tears down on a later tick than the keypress that requested it; Bun 1.4.3 started issuing the next blocking read before that tick, so the teardown parked behind a read that only the next line of input could satisfy. Only `process.stdin` gets Bun's poll-driven native reader. `ControllingTerminalInput` rebuilds that path for an arbitrary terminal device: bytes come from `Bun.file(devicePath).stream()`, whose pending read is poll-driven and whose `cancel()` releases the event loop, while raw mode goes through a dormant `tty.ReadStream` on a second descriptor since termios state belongs to the device. The device must be the real node (`/dev/ttys003`, `/dev/pts/4`): macOS kqueue rejects the `/dev/tty` alias with EINVAL, so `resolveControllingTerminalDevice` follows `/proc/self/fd` on Linux and matches `rdev` among `/dev` terminal entries elsewhere. When no device resolves, startup keeps its existing "no interactive terminal" fallback. Verified in the PTY harness on Bun 1.4.2 and 1.4.4: `git diff | hunk pager`, `echo | hunk diff`, `hunk pager < file`, and `hunk diff` all exit on `q`; `test/pty/pager.test.ts` passes on both. --- .../hunk/src/core/process/terminal.test.ts | 166 +++++++++++++--- packages/hunk/src/core/process/terminal.ts | 185 +++++++++++++++++- packages/hunk/src/ui/runInteractiveApp.tsx | 4 +- 3 files changed, 314 insertions(+), 41 deletions(-) diff --git a/packages/hunk/src/core/process/terminal.test.ts b/packages/hunk/src/core/process/terminal.test.ts index d98aca1fd..5cd800d00 100644 --- a/packages/hunk/src/core/process/terminal.test.ts +++ b/packages/hunk/src/core/process/terminal.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { CliInput } from "../run/commandInputs"; import { + type ControllingTerminalDeps, installTerminalDisconnectSupport, openControllingTerminal, + resolveControllingTerminalDevice, resolveRuntimeCliInput, shouldUseMouseForApp, shouldUsePagerMode, @@ -102,47 +104,151 @@ describe("app mouse support", () => { }); }); -describe("controlling terminal attachment", () => { - test("opens /dev/tty for read and closes the input stream", () => { - const calls: Array<[string, string]> = []; - let stdinDestroyed = false; +/** Build controlling-terminal deps around an in-memory device so no real tty is touched. */ +function createTestControllingTerminalDeps( + overrides: Partial & { + /** Chunks the fake device delivers, in order; the stream stays open after the last one. */ + chunks?: Uint8Array[]; + } = {}, +) { + const log: string[] = []; + const rawModeCalls: boolean[] = []; + const { chunks = [], ...depOverrides } = overrides; + const pending = chunks.map((chunk) => chunk); + let cancelled = false; + let release: (() => void) | undefined; + const source = new ReadableStream({ + pull(controller) { + const next = pending.shift(); + if (next) { + controller.enqueue(next); + return; + } + // Emulate a terminal with nothing typed yet: the read stays pending until cancel. + return new Promise((resolve) => { + release = resolve; + }); + }, + cancel() { + cancelled = true; + release?.(); + }, + }); + const deps: ControllingTerminalDeps = { + candidateFds: [1], + isatty: () => true, + readlinkSync: () => { + throw new Error("no procfs"); + }, + fstatSync: () => ({ rdev: 42 }), + readdirSync: () => ["null", "tty", "ttys001", "ttys007"], + statSync: (path) => ({ + rdev: path === "/dev/ttys007" ? 42 : 7, + isCharacterDevice: () => true, + }), + openSync: (path, flags) => { + log.push(`open ${String(path)} ${String(flags)}`); + return 11; + }, + closeSync: (fd) => { + log.push(`close ${fd}`); + }, + createRawModeStream: () => { + const handle = { + isRaw: false, + setRawMode(flag: boolean) { + rawModeCalls.push(flag); + handle.isRaw = flag; + return handle as never; + }, + }; + return handle; + }, + openDeviceStream: (devicePath) => { + log.push(`stream ${devicePath}`); + return source; + }, + ...depOverrides, + }; + return { + deps, + log, + rawModeCalls, + get cancelled() { + return cancelled; + }, + }; +} - const stdin = { - destroy() { - stdinDestroyed = true; +describe("controlling terminal device resolution", () => { + test("prefers the procfs link for a terminal descriptor", () => { + const { deps } = createTestControllingTerminalDeps({ + readlinkSync: (path) => { + expect(path).toBe("/proc/self/fd/1"); + return "/dev/pts/4"; }, - } as never; + }); + expect(resolveControllingTerminalDevice(deps)).toBe("/dev/pts/4"); + }); - const controllingTerminal = openControllingTerminal({ - openSync(path, flags) { - calls.push([String(path), String(flags)]); - return 11; - }, - createReadStream(fd) { - expect(fd).toBe(11); - return stdin; - }, + test("matches the device by rdev when procfs is unavailable, skipping the /dev/tty alias", () => { + const { deps } = createTestControllingTerminalDeps(); + expect(resolveControllingTerminalDevice(deps)).toBe("/dev/ttys007"); + }); + + test("skips descriptors that are not terminals and reports null without a match", () => { + const { deps } = createTestControllingTerminalDeps({ + candidateFds: [1, 2], + isatty: (fd) => fd === 2, + fstatSync: () => ({ rdev: 99 }), }); + expect(resolveControllingTerminalDevice(deps)).toBeNull(); + }); +}); +describe("controlling terminal attachment", () => { + test("opens the resolved device for raw mode and streams it without a blocking read", async () => { + const harness = createTestControllingTerminalDeps({ + chunks: [new TextEncoder().encode("q")], + }); + const controllingTerminal = openControllingTerminal(harness.deps); expect(controllingTerminal).not.toBeNull(); - expect(calls).toEqual([["/dev/tty", "r"]]); - expect(controllingTerminal?.stdin).toBe(stdin); + expect(harness.log).toEqual(["open /dev/ttys007 r", "stream /dev/ttys007"]); + + const stdin = controllingTerminal!.stdin; + expect(stdin.isTTY).toBe(true); + stdin.setRawMode(true); + expect(stdin.isRaw).toBe(true); + expect(harness.rawModeCalls).toEqual([true]); + + const first = await new Promise((resolve) => stdin.once("data", resolve)); + expect(first.toString()).toBe("q"); - controllingTerminal?.close(); - expect(stdinDestroyed).toBe(true); + // Teardown lands on a later tick while the next read is already pending, which is the + // shape that used to strand the process behind a blocking read. + await new Promise((resolve) => setTimeout(resolve, 0)); + const closed = new Promise((resolve) => stdin.once("close", () => resolve())); + controllingTerminal!.close(); + await closed; + expect(harness.cancelled).toBe(true); + expect(harness.log.at(-1)).toBe("close 11"); + expect(stdin.destroyed).toBe(true); }); - test("returns null when the controlling terminal cannot be opened", () => { - const controllingTerminal = openControllingTerminal({ - openSync() { - throw new Error("no tty"); - }, - createReadStream() { - throw new Error("unreachable"); + test("returns null when no terminal device resolves", () => { + const { deps, log } = createTestControllingTerminalDeps({ isatty: () => false }); + expect(openControllingTerminal(deps)).toBeNull(); + expect(log).toEqual([]); + }); + + test("returns null and releases the descriptor when the device stream cannot open", () => { + const { deps, log } = createTestControllingTerminalDeps({ + openDeviceStream: () => { + throw new Error("EINVAL"); }, }); - - expect(controllingTerminal).toBeNull(); + expect(openControllingTerminal(deps)).toBeNull(); + expect(log).toEqual(["open /dev/ttys007 r", "close 11"]); }); }); diff --git a/packages/hunk/src/core/process/terminal.ts b/packages/hunk/src/core/process/terminal.ts index c7bdac778..9ef599179 100644 --- a/packages/hunk/src/core/process/terminal.ts +++ b/packages/hunk/src/core/process/terminal.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { Readable } from "node:stream"; import tty from "node:tty"; import type { CliInput } from "../run/commandInputs"; @@ -40,7 +41,7 @@ export function shouldUseMouseForApp({ } export interface ControllingTerminal { - stdin: tty.ReadStream; + stdin: ControllingTerminalInput; close: () => void; } @@ -97,25 +98,189 @@ export function installTerminalDisconnectSupport( }; } -/** Minimal terminal construction hooks so tests can cover `/dev/tty` attach behavior. */ +/** Terminal construction hooks so tests can cover device resolution and attach behavior. */ export interface ControllingTerminalDeps { + /** Descriptors that may be attached to the terminal, checked in order. */ + candidateFds: number[]; + isatty: typeof tty.isatty; + readlinkSync: (path: string) => string; + fstatSync: (fd: number) => { rdev: number }; + readdirSync: (path: string) => string[]; + statSync: (path: string) => { rdev: number; isCharacterDevice(): boolean }; openSync: typeof fs.openSync; - createReadStream: (fd: number) => tty.ReadStream; + closeSync: typeof fs.closeSync; + /** Builds the dormant raw-mode handle for an open device descriptor. */ + createRawModeStream: (fd: number) => Pick; + /** Opens the poll-driven byte source for a terminal device path. */ + openDeviceStream: (devicePath: string) => ReadableStream; +} + +const defaultControllingTerminalDeps: ControllingTerminalDeps = { + candidateFds: [1, 2], + isatty: tty.isatty, + readlinkSync: (path) => fs.readlinkSync(path), + fstatSync: (fd) => fs.fstatSync(fd), + readdirSync: (path) => fs.readdirSync(path), + statSync: (path) => fs.statSync(path), + openSync: fs.openSync, + closeSync: fs.closeSync, + createRawModeStream: (fd) => new tty.ReadStream(fd), + openDeviceStream: (devicePath) => Bun.file(devicePath).stream(), +}; + +/** + * Resolve the real device node (`/dev/ttys003`, `/dev/pts/4`) behind the terminal that + * `candidateFds` write to. The `/dev/tty` alias is not enough: macOS kqueue refuses to poll it + * with EINVAL, so the non-blocking reader below needs the concrete node. Linux answers through + * `/proc/self/fd`; elsewhere the device is matched by `rdev` among the `/dev` terminal entries. + */ +export function resolveControllingTerminalDevice( + deps: ControllingTerminalDeps = defaultControllingTerminalDeps, +): string | null { + for (const fd of deps.candidateFds) { + if (!deps.isatty(fd)) continue; + try { + const target = deps.readlinkSync(`/proc/self/fd/${fd}`); + if (target.startsWith("/dev/")) return target; + } catch { + // No procfs; fall through to the device scan. + } + let rdev: number; + try { + rdev = deps.fstatSync(fd).rdev; + } catch { + continue; + } + let entries: string[]; + try { + entries = deps.readdirSync("/dev"); + } catch { + continue; + } + for (const name of entries) { + // `tty` itself is the alias we must avoid; the per-session nodes carry a suffix. + if (name === "tty" || !(name.startsWith("tty") || name.startsWith("pts"))) continue; + const devicePath = `/dev/${name}`; + try { + const stat = deps.statSync(devicePath); + if (stat.isCharacterDevice() && stat.rdev === rdev) return devicePath; + } catch { + // Entries can disappear mid-scan; keep looking. + } + } + } + return null; +} + +/** + * Streams keyboard input from a terminal device without parking a blocking read on Bun's thread + * pool. + * + * Bun implements `tty.ReadStream(fd)` as `fs.ReadStream`, so each read is a blocking `read(2)` on a + * worker thread and `destroy()` waits for that read to return, which only happens when the + * terminal delivers more input. Bun >= 1.4.3 issues the next read before the app's asynchronous + * teardown runs, so `git diff | hunk pager` would stay alive after `q` until the user pressed + * Enter. Only `process.stdin` gets Bun's poll-driven native reader; this class rebuilds that path + * for an arbitrary device: bytes come from `Bun.file(devicePath).stream()`, whose pending read is + * poll-driven and whose `cancel()` releases the event loop, while raw mode goes through a dormant + * `tty.ReadStream` on a second descriptor because termios state belongs to the device, not to a + * descriptor. + * + * Invariants: + * - At most one native read is in flight; `_read` is a no-op while one is pending. + * - `_destroy` cancels the native reader before closing the raw-mode descriptor, so an in-flight + * read settles as done instead of holding the process open. + */ +export class ControllingTerminalInput extends Readable { + readonly isTTY = true; + isRaw = false; + readonly fd: number; + readonly #rawMode: Pick; + readonly #source: ReadableStream; + readonly #closeSync: typeof fs.closeSync; + #reader: ReadableStreamDefaultReader | undefined; + #reading = false; + + constructor(devicePath: string, deps: ControllingTerminalDeps = defaultControllingTerminalDeps) { + super(); + this.fd = deps.openSync(devicePath, "r"); + this.#closeSync = deps.closeSync; + try { + this.#rawMode = deps.createRawModeStream(this.fd); + this.#source = deps.openDeviceStream(devicePath); + } catch (error) { + deps.closeSync(this.fd); + throw error; + } + } + + /** Switch the terminal between raw and cooked mode, mirroring `tty.ReadStream#setRawMode`. */ + setRawMode(flag: boolean): this { + this.#rawMode.setRawMode(flag); + this.isRaw = this.#rawMode.isRaw; + return this; + } + + /** The poll-driven reader already keeps the loop alive only while a read is pending. */ + ref(): this { + return this; + } + + unref(): this { + return this; + } + + override _read(): void { + if (this.#reading) return; + const reader = (this.#reader ??= this.#source.getReader()); + this.#reading = true; + reader.read().then( + ({ value, done }) => { + this.#reading = false; + // A cancelled reader settles as done after destroy; the stream already finished. + if (reader !== this.#reader) return; + if (done || value === undefined) { + this.push(null); + return; + } + this.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength)); + }, + (error: unknown) => { + this.#reading = false; + if (reader === this.#reader) + this.destroy(error instanceof Error ? error : new Error(String(error))); + }, + ); + } + + override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + const reader = this.#reader; + this.#reader = undefined; + const release = () => { + try { + this.#closeSync(this.fd); + } catch { + // The descriptor is already gone; nothing else holds the device. + } + callback(error); + }; + const cancelled = reader ? reader.cancel() : this.#source.cancel(); + cancelled.then(release, release); + } } /** * Open the controlling terminal for input so the UI can stay interactive while stdin carries patch - * data. Rendering can continue through the existing stdout stream. + * data. Rendering continues through the existing stdout stream. Returns null when no terminal + * device can be resolved or opened, which callers treat as "no interactive terminal". */ export function openControllingTerminal( - deps: ControllingTerminalDeps = { - openSync: fs.openSync, - createReadStream: (fd) => new tty.ReadStream(fd), - }, + deps: ControllingTerminalDeps = defaultControllingTerminalDeps, ): ControllingTerminal | null { try { - const stdinFd = deps.openSync("/dev/tty", "r"); - const stdin = deps.createReadStream(stdinFd); + const devicePath = resolveControllingTerminalDevice(deps); + if (!devicePath) return null; + const stdin = new ControllingTerminalInput(devicePath, deps); return { stdin, diff --git a/packages/hunk/src/ui/runInteractiveApp.tsx b/packages/hunk/src/ui/runInteractiveApp.tsx index 730e714d2..573792743 100644 --- a/packages/hunk/src/ui/runInteractiveApp.tsx +++ b/packages/hunk/src/ui/runInteractiveApp.tsx @@ -33,7 +33,9 @@ export async function runInteractiveApp( ): Promise { const createReviewRuntime = deps.createReviewRuntime ?? createReviewSessionRuntime; const runSession = deps.runSession ?? runHunkSession; - const rendererStdin = controllingTerminal?.stdin ?? process.stdin; + // OpenTUI types its input as the socket-derived `NodeJS.ReadStream` but only uses the Readable + // surface plus `setRawMode`, which the controlling-terminal input provides. + const rendererStdin = (controllingTerminal?.stdin ?? process.stdin) as NodeJS.ReadStream; let terminalClosed = false; const closeTerminal = () => { if (terminalClosed) return; From 32a82144c6ddd4e8e4c53bd28a3348fc02c68b72 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 16:54:53 -0400 Subject: [PATCH 3/7] build(nix): replace the dev shell's bun on Linux too Bun bundles its own root store on every platform, so a Linux `nix develop` shell was still handing out nixpkgs' bun 1.3.13, which trusts the compromised eMudhra intermediates. Add the bun-linux-x64 and bun-linux-aarch64 release assets (the same flavors nixpkgs selects, hashes from the bun-v1.4.4 SHASUMS256.txt) and gate only the node override on darwin, where the prebuilt nodejs.org tarball applies. Verified by evaluation: devShells.{x86_64,aarch64}-linux resolve bun-1.4.4 from the matching asset while keeping nixpkgs' nodejs-24.14.1; the darwin shell is unchanged (bun 1.4.4, node 24.22.1, probe still rejects the chain). No Linux builder is available locally, so the Linux derivation itself was not built here; it reuses nixpkgs' unzip + autoPatchelf recipe unchanged. --- flake.nix | 4 ++-- nix/emudhra-overlay.nix | 45 ++++++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/flake.nix b/flake.nix index 173ad9f0d..727f4d074 100644 --- a/flake.nix +++ b/flake.nix @@ -38,8 +38,8 @@ system: let pkgs = import nixpkgs { inherit system; - # TEMPORARY: eMudhra root-store workaround for the dev shell's node and - # bun on macOS; self-retiring, see nix/emudhra-overlay.nix. + # TEMPORARY: eMudhra root-store workaround for the dev shell's bun (all + # systems) and node (macOS); self-retiring, see nix/emudhra-overlay.nix. overlays = [(import ./nix/emudhra-overlay.nix)]; }; hunk = pkgs.callPackage ./nix/package.nix { diff --git a/nix/emudhra-overlay.nix b/nix/emudhra-overlay.nix index 7702d1e5f..b6c2a0a7b 100644 --- a/nix/emudhra-overlay.nix +++ b/nix/emudhra-overlay.nix @@ -1,13 +1,15 @@ # Swaps the Nix dev shell's Node and Bun for the first releases that distrust the -# compromised eMudhra (emSign) intermediates, on macOS only. +# compromised eMudhra (emSign) intermediates. # -# TEMPORARY eMudhra workaround. Node and Bun carry their own root stores, so the -# nix-darwin `security.pki.caCertificateBlacklist` fix does not reach them; the -# first upstream releases whose stores reject the compromised chain are +# TEMPORARY eMudhra workaround. Node and Bun carry their own root stores, so +# system trust-store fixes (such as nix-darwin's +# `security.pki.caCertificateBlacklist`) do not reach them; the first upstream +# releases whose stores reject the compromised chain are # node 24.22.1 (NSS 3.131.1 roots + blocked-SPKI check, 2026-10-20) # bun 1.4.4 (roots updated to NSS 3.131.1, 2026-10-22) -# while the flake's nixpkgs pin still carries older builds. Linux shells keep -# nixpkgs' packages untouched (their trust comes from the system store). +# while the flake's nixpkgs pin still carries older builds. Bun is replaced on +# every supported system. Node is replaced on macOS only, where the prebuilt +# nodejs.org tarball in nodejs-bin.nix applies; Linux shells keep nixpkgs' node. # # Invariants: # - Self-retiring: each replacement applies only while the pinned nixpkgs version @@ -32,9 +34,11 @@ let # Official nodejs.org tarball; see the header of nodejs-bin.nix. nodejsBin = import ./nodejs-bin.nix { pkgs = prev; }; - # nixpkgs' darwin bun is already "unzip the official GitHub release asset", so - # only version and src move; the install_name_tool/codesign/completion hooks are - # reused as-is. Hashes come from SHASUMS256.txt on the bun-v1.4.4 release. + # nixpkgs' bun is already "unzip the official GitHub release asset", so only + # version and src move; the autoPatchelf (Linux) and install_name_tool/codesign + # (macOS) hooks and completions are reused as-is. Each system takes the same + # asset flavor nixpkgs selects for it. Hashes come from SHASUMS256.txt on the + # bun-v1.4.4 release. bunVersion = "1.4.4"; bunSrc = { "aarch64-darwin" = prev.fetchurl { @@ -45,20 +49,29 @@ let url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-darwin-x64-baseline.zip"; hash = "sha256-1KFVrfv9CRVes7oFyeOlRPOSl9emhhbTx3WfZ6tcR9w="; }; + "aarch64-linux" = prev.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-linux-aarch64.zip"; + hash = "sha256-uqa7NvUdblhWHgK76oJQOKnfRU9nPW13bou/hvjHhQ4="; + }; + "x86_64-linux" = prev.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/bun-linux-x64.zip"; + hash = "sha256-v4zL+ysLgBWeD5ptFcVTF63YB3U028YTCj7bUvzLOVw="; + }; }; bunBin = prev.bun.overrideAttrs (_: { version = bunVersion; src = bunSrc.${system} or (throw "emudhra-overlay.nix: no bun ${bunVersion} hash for ${system}"); }); - # Both the darwin gate and the version gate are evaluated lazily inside each - # value, so non-darwin systems never force the darwin-only derivations. - pick = want: replacement: orig: if isDarwin && want then replacement else orig; + # Gates are evaluated lazily inside each value, so a system that keeps its + # nixpkgs package never forces the replacement derivation. + pick = want: replacement: orig: if want then replacement else orig; + wantNodeHere = isDarwin && wantNode; in { - nodejs = pick wantNode nodejsBin prev.nodejs; - nodejs_24 = pick wantNode nodejsBin prev.nodejs_24; - nodejs-slim = pick wantNode nodejsBin prev.nodejs-slim; - nodejs-slim_24 = pick wantNode nodejsBin prev.nodejs-slim_24; + nodejs = pick wantNodeHere nodejsBin prev.nodejs; + nodejs_24 = pick wantNodeHere nodejsBin prev.nodejs_24; + nodejs-slim = pick wantNodeHere nodejsBin prev.nodejs-slim; + nodejs-slim_24 = pick wantNodeHere nodejsBin prev.nodejs-slim_24; bun = pick wantBun bunBin prev.bun; } From ada3ce5f74cad383d3a692e35ae5f2f53337222e Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 18:35:26 -0400 Subject: [PATCH 4/7] docs(ui): list the stdin members OpenTUI reads from the controlling terminal The comment on the renderer-stdin cast said OpenTUI only uses "the Readable surface plus setRawMode", which leaves out `isTTY`. OpenTUI gates its terminal palette (OSC) queries on `stdin.isTTY`, and Hunk's disconnect watcher reads it too, so dropping it from the controlling-terminal input would silently disable palette detection rather than fail. Name the members OpenTUI 0.5.x actually reads (audited in the Bun build of `CliRenderer` and `TerminalPalette`) and note that the cast cannot catch upstream drift, so `CliRenderer`'s stdin use needs a re-audit on `@opentui/core` bumps. --- packages/hunk/src/ui/runInteractiveApp.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/hunk/src/ui/runInteractiveApp.tsx b/packages/hunk/src/ui/runInteractiveApp.tsx index 573792743..fba11c4be 100644 --- a/packages/hunk/src/ui/runInteractiveApp.tsx +++ b/packages/hunk/src/ui/runInteractiveApp.tsx @@ -33,8 +33,10 @@ export async function runInteractiveApp( ): Promise { const createReviewRuntime = deps.createReviewRuntime ?? createReviewSessionRuntime; const runSession = deps.runSession ?? runHunkSession; - // OpenTUI types its input as the socket-derived `NodeJS.ReadStream` but only uses the Readable - // surface plus `setRawMode`, which the controlling-terminal input provides. + // OpenTUI 0.5.x declares the socket-backed `NodeJS.ReadStream` but only reads `isTTY`, + // `setRawMode`, `on`/`removeListener("data")`, `pause`/`resume`, `read`, and `readableLength`, + // all of which the controlling-terminal input provides. The cast cannot catch OpenTUI drift; + // re-audit `CliRenderer`'s stdin use when bumping `@opentui/core`. const rendererStdin = (controllingTerminal?.stdin ?? process.stdin) as NodeJS.ReadStream; let terminalClosed = false; const closeTerminal = () => { From bb185f5143e2cad15b8a4cef1ed1a69f973bffda Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 18:36:29 -0400 Subject: [PATCH 5/7] refactor(ui): type the renderer stdin structurally instead of casting it `runInteractiveApp` cast the controlling-terminal input to `NodeJS.ReadStream` before handing it to the session runner. Because the cast covered the whole union, removing `setRawMode` (or `isTTY`) from `ControllingTerminalInput` still compiled, and OpenTUI guards its raw-mode calls with `if (stdin.setRawMode)`, so the regression would have shipped as a terminal silently left in cooked mode. Add `RendererInput`, the members OpenTUI 0.5.x and the disconnect watcher actually read, and use it for `HunkSessionRunnerOptions.stdin`. Both `process.stdin` and `ControllingTerminalInput` satisfy it without a cast; the one remaining cast sits at the `createCliRenderer` call, where OpenTUI's declared type is wider than what it uses. Verified the guard: renaming `setRawMode` on the class now fails `bun run typecheck` at `runInteractiveApp.tsx`. The type still cannot catch OpenTUI starting to read a new stdin member, so its doc comment asks for a re-audit on `@opentui/core` bumps. --- packages/hunk/src/ui/runInteractiveApp.tsx | 9 +++----- .../hunk/src/ui/session/runHunkSession.tsx | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/hunk/src/ui/runInteractiveApp.tsx b/packages/hunk/src/ui/runInteractiveApp.tsx index fba11c4be..096ac76ae 100644 --- a/packages/hunk/src/ui/runInteractiveApp.tsx +++ b/packages/hunk/src/ui/runInteractiveApp.tsx @@ -7,7 +7,7 @@ import type { InteractiveSessionInitialization } from "../core/session/initializ import { createExtensionSession } from "../extensions/session"; import type { ExtensionLoadResult } from "../extensions/types"; import { HunkSessionHost, type StandaloneReviewSurfaceRoute } from "./session/HunkSessionHost"; -import { runHunkSession } from "./session/runHunkSession"; +import { runHunkSession, type RendererInput } from "./session/runHunkSession"; export interface InteractiveAppInput { bootstrap: AppBootstrap; @@ -33,11 +33,8 @@ export async function runInteractiveApp( ): Promise { const createReviewRuntime = deps.createReviewRuntime ?? createReviewSessionRuntime; const runSession = deps.runSession ?? runHunkSession; - // OpenTUI 0.5.x declares the socket-backed `NodeJS.ReadStream` but only reads `isTTY`, - // `setRawMode`, `on`/`removeListener("data")`, `pause`/`resume`, `read`, and `readableLength`, - // all of which the controlling-terminal input provides. The cast cannot catch OpenTUI drift; - // re-audit `CliRenderer`'s stdin use when bumping `@opentui/core`. - const rendererStdin = (controllingTerminal?.stdin ?? process.stdin) as NodeJS.ReadStream; + // Checked structurally so a controlling-terminal input missing a renderer member fails to compile. + const rendererStdin: RendererInput = controllingTerminal?.stdin ?? process.stdin; let terminalClosed = false; const closeTerminal = () => { if (terminalClosed) return; diff --git a/packages/hunk/src/ui/session/runHunkSession.tsx b/packages/hunk/src/ui/session/runHunkSession.tsx index c5529691e..ac4c6b3f3 100644 --- a/packages/hunk/src/ui/session/runHunkSession.tsx +++ b/packages/hunk/src/ui/session/runHunkSession.tsx @@ -23,8 +23,24 @@ export interface HunkSessionRenderContext { finish(exitCode?: number): void; } +/** + * Describes the stdin surface the renderer and disconnect watcher rely on, so both `process.stdin` + * and the controlling-terminal input are checked structurally instead of cast. + * + * OpenTUI 0.5.x reads `isTTY`, `setRawMode`, `on`/`removeListener("data")`, `pause`/`resume`, + * `read`, and `readableLength`; the disconnect watcher adds its `TerminalInputEvents`. OpenTUI + * declares the whole socket-backed `NodeJS.ReadStream`, so this type cannot catch it reading a new + * member: re-audit `CliRenderer`'s stdin use when bumping `@opentui/core`. + */ +export type RendererInput = NodeJS.ReadableStream & + TerminalInputEvents & { + readonly isTTY: boolean; + readonly readableLength: number; + setRawMode(mode: boolean): unknown; + }; + export interface HunkSessionRunnerOptions { - stdin: NodeJS.ReadStream & TerminalInputEvents; + stdin: RendererInput; stdout: NodeJS.WriteStream; useMouse: boolean; signals: readonly NodeJS.Signals[]; @@ -117,7 +133,8 @@ export async function runHunkSession( try { renderer = await createRenderer({ - stdin: options.stdin, + // OpenTUI declares the socket-backed `NodeJS.ReadStream` but only touches `RendererInput`. + stdin: options.stdin as NodeJS.ReadStream, stdout: options.stdout, useMouse: options.useMouse, screenMode: "alternate-screen", From e99f14ca950e401f5847163a511f941e25f0117a Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 18:39:18 -0400 Subject: [PATCH 6/7] docs(pager): document the controlling-terminal input's liveness and raw-mode invariants The `ref()` comment said the device stream keeps the event loop alive "only while a read is pending". In Bun 1.4.4 it holds the loop from the first read until `destroy()` cancels the reader; completed reads, `pause()`, and `unref()` do not release it. That is what keeps `hunk pager` alive while idle (OpenTUI's keep-alive timer only runs for `process.stdin`), and it means exit depends on `ControllingTerminal.close`, so say so instead. Also record the two invariants the dormant raw-mode `tty.ReadStream` relies on, both verified against Bun 1.4.3/1.4.4 source and in a PTY: it must never be read from (a flowing handle reintroduces the blocking read and steals keystrokes), and its descriptor is closed with `closeSync`, so raw mode must be left before destroy and the handle not touched after. Comment-only; no behavior change. --- packages/hunk/src/core/process/terminal.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/hunk/src/core/process/terminal.ts b/packages/hunk/src/core/process/terminal.ts index 9ef599179..29d49b45d 100644 --- a/packages/hunk/src/core/process/terminal.ts +++ b/packages/hunk/src/core/process/terminal.ts @@ -190,6 +190,14 @@ export function resolveControllingTerminalDevice( * - At most one native read is in flight; `_read` is a no-op while one is pending. * - `_destroy` cancels the native reader before closing the raw-mode descriptor, so an in-flight * read settles as done instead of holding the process open. + * - The raw-mode `tty.ReadStream` stays dormant: Bun's `fs.ReadStream` only issues `read(2)` from + * `_read`, which runs once something calls `read()`/`resume()`/`pipe()` or attaches a + * `data`/`readable` listener. Never do any of those on it; a flowing handle would steal + * keystrokes from the device stream and park the blocking read this class exists to avoid. + * - That handle owns no native resource or finalizer, so `_destroy` closes its descriptor with + * `closeSync` instead of destroying the stream. Callers leave raw mode before destroying + * (OpenTUI does so before `onDestroy`); after `_destroy` the descriptor number may belong to an + * unrelated file, so the handle must not be touched again. */ export class ControllingTerminalInput extends Readable { readonly isTTY = true; @@ -221,11 +229,18 @@ export class ControllingTerminalInput extends Readable { return this; } - /** The poll-driven reader already keeps the loop alive only while a read is pending. */ + /** + * Accept `ref()` for `tty.ReadStream` shape compatibility only; it does not change event-loop + * liveness. Once the first read is issued, the device stream keeps the loop alive until + * `destroy()` cancels the reader; completed reads, `pause()`, and `unref()` do not release it. + * That keeps the pager alive while idle (OpenTUI's keep-alive timer only runs for + * `process.stdin`), so callers must `destroy()` through `ControllingTerminal.close` to exit. + */ ref(): this { return this; } + /** Accept `unref()` as a no-op; see `ref()`. Only `destroy()` releases the loop. */ unref(): this { return this; } From bd4c07d5a5599c503ff383d19be17bd0fbb04675 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 22 Oct 2026 18:44:33 -0400 Subject: [PATCH 7/7] docs(nix): correct the pinned nixpkgs node version in nodejs-bin.nix The header said the flake's nixpkgs pin carries Node 24.21.0; it carries 24.14.1 (`nodejs_24` and `nodejs-slim_24` both evaluate to 24.14.1 at the locked revision), matching the PR description and the Linux dev shell. Comment-only; no evaluation change. --- nix/nodejs-bin.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/nodejs-bin.nix b/nix/nodejs-bin.nix index d298b058f..d5a54e441 100644 --- a/nix/nodejs-bin.nix +++ b/nix/nodejs-bin.nix @@ -4,7 +4,7 @@ # nix-darwin `security.pki.caCertificateBlacklist` fix does not reach it. Node # 24.22.1 (2026-10-20) is the first release whose root store (NSS 3.131.1 plus the # blocked-SPKI check) rejects the compromised eMudhra intermediates; the flake's -# nixpkgs pin still carries 24.21.0. `nix/emudhra-overlay.nix` substitutes this +# nixpkgs pin still carries 24.14.1. `nix/emudhra-overlay.nix` substitutes this # derivation for `nodejs`/`nodejs_24` only while that pin is older than 24.22.1, # so the file retires itself once `nix flake update` catches up. #