diff --git a/.changeset/turbo-android-native.md b/.changeset/turbo-android-native.md new file mode 100644 index 00000000..777e318b --- /dev/null +++ b/.changeset/turbo-android-native.md @@ -0,0 +1,17 @@ +--- +'@gtbuchanan/pnpm-termux-shim': patch +'@gtbuchanan/cli': minor +--- + +Run the npm-distributed turbo on Termux instead of a Termux-packaged one. +turbo 2.10.8 publishes its `linux-arm64` binary under `os: ["android", +"linux"]`, so `node_modules/.bin/turbo` now starts on Android and the +`pkg install turbo` escape hatch in `gtb turbo` is gone. + +In its place `gtb turbo` rewrites every PATH entry to an absolute path. +turbo resolves the package manager against PATH from the directory it was +invoked in and keeps the path that search produced, then runs each task +with that package's directory as the cwd — so a match from a relative +entry is re-interpreted against the child's directory and the spawn +fails. pnpm always prepends a relative `./node_modules/.bin`, which is +exactly where `@gtbuchanan/pnpm-termux-shim` installs its `pnpm`. diff --git a/AGENTS.md b/AGENTS.md index 75412960..3627e359 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,9 +52,9 @@ consumer repos. Authored in this repo; deployed locally via `gtb task deploy:skills` for dogfooding. - **`gtb-build-pipeline`** (`@gtbuchanan/cli`) — Turborepo task graph, - `gtb sync` / `gtb verify` / `gtb turbo` (with the Android/Termux - escape hatch), consumer script customization, test-bucket strategy, - aggregate semantics + `gtb sync` / `gtb verify` / `gtb turbo` (PATH normalization, plus + Android/Termux setup), consumer script customization, test-bucket + strategy, aggregate semantics - **`gtb-eslint-config`** (`@gtbuchanan/eslint-config`) — `configure()` API and options, pre-commit `createRequire` pattern, bundled plugin set, suppression conventions, two-plugin Markdown lint split, @@ -456,13 +456,15 @@ reasoning here so the question doesn't get re-litigated: - Mise's only registered backend for turbo is `npm:turbo` (no aqua / ubi / cargo fallback). Confirmed via `mise registry turbo`. - Vercel ships turbo exclusively through npm. The latest release has - no binary assets on GitHub Releases; + no binary assets on GitHub Releases. [vercel/turborepo#5616](https://github.com/vercel/turborepo/issues/5616) - (request for Android binaries) was closed as "not planned". + (request for Android binaries) was closed as "not planned", then + reversed by [vercel/turborepo#12735](https://github.com/vercel/turborepo/pull/12735) - is the live PR re-litigating that decision — if it (or anything - like it) lands, upstream distribution shifts and this whole - trade-off changes. + in turbo 2.10.8 — but that shipped Android as an `os` widening of the + existing npm `@turbo/linux-arm64` package, not as standalone release + assets, so the distribution channel is unchanged and so is this + trade-off. - mise's npm backend writes only `version` + `backend` to `mise.lock` — no per-platform integrity. `pnpm-lock.yaml` records per-platform integrity for `turbo` plus every `@turbo/-` optional diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50920a95..8316139d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,6 @@ disable_tools = ["node", "pnpm", "hk", "pkl", "actionlint"] from source on Bionic, which doesn't compile. mise picks up system node from `PATH`. - **pnpm** — `npm i -g pnpm` (no `android/arm64` aqua asset). -- **turbo** — `pkg install turbo`. - **hk** — download the static musl aarch64 release tarball (`hk-aarch64-unknown-linux-musl.tar.gz`); it runs unmodified on Bionic. diff --git a/package.json b/package.json index f0f24c55..d86c1b3a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "valibot": "catalog:", "vitest": "catalog:" }, + "optionalDependencies": { + "@gtbuchanan/pnpm-termux-shim": "catalog:" + }, "packageManager": "pnpm@11.17.0", "engines": { "node": ">=24.0.0", diff --git a/packages/cli/README.md b/packages/cli/README.md index 9e35e475..3bfa3edb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,7 +24,7 @@ This reconciles `turbo.json`, tsconfigs, `package.json` scripts, and or changing capabilities. Run the pipeline via the `gtb turbo` wrapper (generated root scripts -delegate to it so Android/Termux users get a transparent escape hatch): +delegate to it so its PATH normalization applies everywhere): ```sh pnpm exec gtb turbo run check # compile → lint + test:fast (parallel) @@ -37,30 +37,42 @@ single-package repo (where the root _is_ the package) gets none: a root script named after an aggregate makes turbo re-enter itself, so the `gtb turbo run` form above is the entry point there. -`gtb turbo` is a thin pass-through to `turbo` on every supported -platform. On Android, `process.platform === 'android'` causes the -node_modules launcher to refuse to start; the wrapper resolves the -native turbo from Termux's package registry and execs it directly. -Install it once per Termux environment: - -```sh -pkg install turbo +`gtb turbo` runs `turbo` with one adjustment: every PATH entry is +rewritten to an absolute path first. Turbo resolves the package manager +binary against PATH from the directory it was invoked in, keeps the path +that search produced, then runs each task with that package's directory +as the cwd. A match from a relative entry is therefore a relative +program path, re-interpreted against the child's directory — so turbo +reports `unable to spawn child process` instead of falling through to a +later absolute entry. pnpm always prepends a relative +`./node_modules/.bin`, so any bin named after the package manager that +lives there is affected. + +That is the layout on Termux/Android, where +[`@gtbuchanan/pnpm-termux-shim`](../pnpm-termux-shim) supplies a working +`pnpm`. Add the shim to your **workspace root** `package.json` +`optionalDependencies` (not inside any individual package — under pnpm +strict layout, only the root's `node_modules/.bin/` is on turbo's PATH +at spawn time): + +```jsonc +{ + "optionalDependencies": { + "@gtbuchanan/pnpm-termux-shim": "^0.1.1", + }, +} ``` -That puts a Bionic-built `turbo` at `$PREFIX/bin/turbo` (typically -`/data/data/com.termux/files/usr/bin/turbo`), which `gtb turbo` -resolves and execs. - -The Termux-pkg turbo is Bionic-built, so its child-process spawns -honor Termux's `LD_PRELOAD` shebang rewriter and resolve -`#!/usr/bin/env ` correctly. -[`@gtbuchanan/pnpm-termux-shim`](../pnpm-termux-shim) is retained -defensively in case turbo reintroduces a glibc npm distribution, or -another glibc binary in the graph needs to spawn `pnpm`. Add it to -your **workspace root** `package.json` `optionalDependencies` (not -inside any individual package — under pnpm strict layout, only the -root's `node_modules/.bin/` is on turbo's PATH at spawn time). The -shim's `os: ["android"]` filter keeps it off non-Android hosts. +The shim's `os: ["android"]` filter keeps it off non-Android hosts, +where nothing named `pnpm` occupies `node_modules/.bin` and the +normalization is a no-op. + +Turbo runs natively on Termux as of +[vercel/turborepo#12735](https://github.com/vercel/turborepo/pull/12735) +(turbo 2.10.8), which ships the `linux-arm64` binary under +`os: ["android", "linux"]`. Earlier versions require the Termux-packaged +turbo (`pkg install turbo`) instead — the npm launcher refuses to start +on Android and pnpm installs no platform binary. The `prepare` script must be declared so pnpm runs it on install to sync skills from installed packages: @@ -92,7 +104,7 @@ generated `package.json` scripts (`"typecheck:ts": "gtb task typecheck:ts"`). | --------- | ------------------------------------------------------- | | `verify` | Validate generated config against workspace state | | `sync` | Reconcile `turbo.json`, tsconfigs, scripts, codecov.yml | -| `turbo` | Run turbo (with an Android escape hatch) | +| `turbo` | Run turbo with cwd-independent PATH entries | | `prepare` | Sync skills from installed packages | ### Task leaves (`gtb task `) diff --git a/packages/cli/e2e/turbo-generate.test.ts b/packages/cli/e2e/turbo-generate.test.ts index ddc48fef..94dd3319 100644 --- a/packages/cli/e2e/turbo-generate.test.ts +++ b/packages/cli/e2e/turbo-generate.test.ts @@ -10,7 +10,7 @@ import { describe, it } from 'vitest'; * turbo.json snapshot: turbo aborts the whole run when a task in `dependsOn` * resolves to no definition, and silently restores nothing when a cached * task declares no outputs. `gtb turbo` is used rather than turbo directly - * so the Android/Termux escape hatch applies. + * so its PATH normalization applies. */ const jsonIndent = 2; diff --git a/packages/cli/skills/gtb-build-pipeline/SKILL.md b/packages/cli/skills/gtb-build-pipeline/SKILL.md index 2aef65f3..8a370026 100644 --- a/packages/cli/skills/gtb-build-pipeline/SKILL.md +++ b/packages/cli/skills/gtb-build-pipeline/SKILL.md @@ -1,6 +1,6 @@ --- name: gtb-build-pipeline -description: Build pipeline guidance for projects using @gtbuchanan/cli. Covers the Turborepo task graph, gtb sync and verify (including scoped runs), the gtb hk pre-commit runner, the gtb turbo wrapper (with the Android/Termux escape hatch), consumer script customization, and test-bucket strategy. Trigger keywords - @gtbuchanan/cli, @gtbuchanan/pnpm-termux-shim, turbo.json, gtb sync, gtb sync mise, gtb verify, gtb verify mise, gtb turbo, gtb task, gtb hk, hk:all, hk:base, mise.tasks.toml, compile:ts, pack:npm, deploy:skills, task graph. +description: Build pipeline guidance for projects using @gtbuchanan/cli. Covers the Turborepo task graph, gtb sync and verify (including scoped runs), the gtb hk pre-commit runner, the gtb turbo wrapper (PATH normalization, plus Android/Termux setup), consumer script customization, and test-bucket strategy. Trigger keywords - @gtbuchanan/cli, @gtbuchanan/pnpm-termux-shim, turbo.json, gtb sync, gtb sync mise, gtb verify, gtb verify mise, gtb turbo, gtb task, gtb hk, hk:all, hk:base, mise.tasks.toml, compile:ts, pack:npm, deploy:skills, task graph, unable to spawn child process, turbo on Termux. --- # @gtbuchanan/cli build pipeline @@ -46,11 +46,14 @@ scripts are unaffected: they call `gtb task ` and never turbo. `gtb verify` reports any root script that shadows an aggregate, so a repo synced before this rule landed is told which scripts to delete. -`gtb turbo` is a thin pass-through to `turbo` on every supported -platform. On Android (`process.platform === 'android'`) it resolves -the global turbo binary installed via Termux's package registry -(`$PREFIX/bin/turbo`) and execs it directly, bypassing the -node_modules launcher (which rejects the platform upfront). See +`gtb turbo` runs `turbo` with every PATH entry rewritten to an absolute +path first. Turbo resolves the package manager against PATH from the +directory it was invoked in and keeps the path that search produced, +then runs each task with that package's directory as the cwd — so a +match from a relative entry is re-interpreted against the child's +directory and the spawn fails. pnpm always prepends a relative +`./node_modules/.bin`, which matters wherever a bin named after the +package manager lives there. See [Android-Termux setup](#android-termux-setup) below. `pnpm verify`, `pnpm prepare`, and `pnpm run gtb ` invoke the CLI directly. @@ -155,30 +158,28 @@ Invoked via mise (`mise run hk:base`) so hk and its tools resolve from mise. The ## Android-Termux setup -Two issues are caused by Termux's Node reporting `process.platform === 'android'`; a third (memory pressure) is unrelated and applies to any low-memory host. Native Android support upstream was declined in [vercel/turborepo#5616](https://github.com/vercel/turborepo/issues/5616), so `gtb turbo` ships the workaround instead. +Two issues are specific to Termux — getting a turbo binary that runs, and getting it to spawn `pnpm`; a third (memory pressure) is unrelated and applies to any low-memory host. -**1. Node_modules launcher rejects android.** The launcher in `node_modules/.bin/turbo` exits early when `process.platform === 'android'`, and pnpm filters `@turbo/-` optional dependencies by host platform so none of the bundled platform binaries are installed either. Install the native turbo from Termux's package registry instead: +**1. Getting a turbo that runs.** Turbo 2.10.8 ships the `linux-arm64` binary under `os: ["android", "linux"]` ([vercel/turborepo#12735](https://github.com/vercel/turborepo/pull/12735), reversing the 2023 decline in [#5616](https://github.com/vercel/turborepo/issues/5616)), so pnpm installs a platform binary on Termux and the `node_modules/.bin/turbo` launcher starts normally. Nothing special is required beyond depending on turbo `^2.10.8`. -```sh -pkg install turbo -``` - -That puts a Bionic-built `turbo` at `$PREFIX/bin/turbo` (typically `/data/data/com.termux/files/usr/bin/turbo`). `gtb turbo` resolves it directly via `$PREFIX` (with the standard prefix as fallback) and execs it, bypassing the node_modules launcher entirely. +Below that version the launcher exits early on `process.platform === 'android'` and pnpm installs no `@turbo/-` binary at all; the workaround was a Termux-packaged turbo (`pkg install turbo`) invoked directly. If you meet a repo pinned to an older turbo, bump it rather than reviving the escape hatch. -**2. Turbo child-process spawn ENOENT (historically).** The npm-distributed Linux turbo binary is glibc-built, but Termux is Bionic. Termux's `LD_PRELOAD=libtermux-exec-ld-preload.so` rewrites `/usr/bin/env` shebangs in `execve` syscalls — but the preload is Bionic-only, so it never loads into a glibc turbo. When such a turbo spawns `pnpm`, the kernel sees `#!/usr/bin/env node` and fails because Termux has no `/usr/bin/env`. +**2. Turbo child-process spawn ENOENT.** Termux has no `/usr/bin/env`, so a binary that `execve`s `pnpm` hits its literal `#!/usr/bin/env node` shebang and fails. Termux's `LD_PRELOAD=libtermux-exec-ld-preload.so` rewrites those shebangs, but `LD_PRELOAD` is a dynamic-loader feature and the npm-distributed turbo is statically linked (no `PT_INTERP`), so the rewriter never reaches it. -The Termux-pkg turbo is Bionic-built, so the preload loads correctly and child-process spawns resolve `pnpm` without issue. `@gtbuchanan/pnpm-termux-shim` is retained defensively in case turbo reintroduces a glibc npm distribution, or another glibc binary in the graph needs to spawn `pnpm`. The shim is an `os: ["android"]`-filtered package whose `bin: { pnpm: ... }` entry has an absolute-path shebang; pnpm symlinks it into `/node_modules/.bin/pnpm` ahead of the system `pnpm` in PATH. On non-Android hosts it's filtered out at install — zero footprint. - -Add it as an `optionalDependencies` entry on the workspace root (so the bin lands in the root's `node_modules/.bin`, not nested under a transitive dep): +The fix is `@gtbuchanan/pnpm-termux-shim`, an `os: ["android"]`-filtered package whose `bin: { pnpm: ... }` entry has an absolute-path shebang. Add it to the **workspace root** `optionalDependencies` — under pnpm's strict layout only the root's `node_modules/.bin/` is on turbo's PATH at spawn time: ```jsonc { "optionalDependencies": { - "@gtbuchanan/pnpm-termux-shim": "^0.1.0", + "@gtbuchanan/pnpm-termux-shim": "^0.1.1", }, } ``` +On non-Android hosts it's filtered out at install — zero footprint. + +Installing the shim is what makes `gtb turbo`'s PATH normalization load-bearing: it puts a `pnpm` inside the relative `./node_modules/.bin` entry pnpm prepends, and turbo carries that relative match into each task's package directory, where it no longer resolves. Symptom is `unable to spawn child process: No such file or directory (os error 2)` on every non-root task. Running turbo through `gtb turbo` (which every generated root script does) resolves it; invoking `turbo` directly from a `pnpm run` script reintroduces it. + **3. Memory-bound concurrency for heavy aggregates.** Unrelated to `process.platform`: phones typically have 2–4GB free RAM under load. Turbo's default `--concurrency=10` is fine for `check` (typecheck + lint + fast tests fan out narrowly under the dependency graph). It is **not** fine for `build`, `test:slow`, or `test:e2e`, which fork their own vitest worker pools per task — `--concurrency=2` already crashed the OS in measurement. Run heavy aggregates with `--concurrency=1` on memory-constrained devices: ```sh diff --git a/packages/cli/skills/gtb-build-pipeline/evals/evals.json b/packages/cli/skills/gtb-build-pipeline/evals/evals.json index ce9246f4..8aafbd6f 100644 --- a/packages/cli/skills/gtb-build-pipeline/evals/evals.json +++ b/packages/cli/skills/gtb-build-pipeline/evals/evals.json @@ -13,21 +13,22 @@ }, { "expectations": [ - "Recommends running `pkg install turbo` to install the native turbo from the Termux package registry", - "Explains that the node_modules launcher rejects `process.platform === 'android'` upfront, and pnpm filters `@turbo/-` optional deps by host platform so none of the bundled platform binaries are installed either", - "Notes that `gtb turbo` resolves `$PREFIX/bin/turbo` (with `/data/data/com.termux/files/usr` as the standard fallback) and execs it directly, bypassing the launcher" + "Says `pkg install turbo` is no longer needed — turbo 2.10.8 publishes the linux-arm64 binary under `os: [\"android\", \"linux\"]`, so pnpm installs it and the node_modules launcher runs on Termux", + "Tells me to depend on turbo `^2.10.8` (bump rather than keep a Termux-packaged turbo)", + "Notes the npm turbo still needs `@gtbuchanan/pnpm-termux-shim` on the workspace root to spawn `pnpm`" ], - "expected_output": "Activates skill. Tells me to run `pkg install turbo` to install the native turbo from the Termux package registry. Explains that the node_modules launcher rejects `process.platform === 'android'` upfront, and pnpm filters `@turbo/-` optional deps by host platform so none of the bundled binaries are installed either. After install the Bionic turbo lands at `$PREFIX/bin/turbo` and `gtb turbo` resolves it directly (honoring `$PREFIX`, with `/data/data/com.termux/files/usr` as fallback) and execs it, bypassing the launcher.", + "expected_output": "Activates skill. Explains that `pkg install turbo` is obsolete: turbo 2.10.8 (vercel/turborepo#12735) ships the `linux-arm64` binary under `os: [\"android\", \"linux\"]`, so pnpm installs a platform binary on Termux and `node_modules/.bin/turbo` starts normally — depend on turbo `^2.10.8`. Notes that the npm-distributed turbo still can't spawn Termux's `pnpm` on its own, so `@gtbuchanan/pnpm-termux-shim` belongs in the workspace root `optionalDependencies`.", "files": [], "id": 2, - "prompt": "When I run `gtb turbo run build` on Termux it says `the global turbo binary is not installed`. How do I fix that?" + "prompt": "I set up this repo on Termux a while back with `pkg install turbo`. Do I still need that, or can I just use the turbo from node_modules now?" }, { "expectations": [ - "Identifies the cause: glibc turbo's `#!/usr/bin/env node` shebang fails because Termux's LD_PRELOAD shebang shim is Bionic-only and never loads into the glibc turbo, while /usr/bin/env doesn't exist in Termux", - "Recommends adding `@gtbuchanan/pnpm-termux-shim` as an `optionalDependencies` entry on the workspace root (not a sub-package)" + "Identifies the cause: Termux has no `/usr/bin/env`, and the `LD_PRELOAD` shebang rewriter never reaches the statically linked npm turbo (no dynamic loader involved), so `#!/usr/bin/env node` fails", + "Recommends adding `@gtbuchanan/pnpm-termux-shim` as an `optionalDependencies` entry on the workspace root (not a sub-package)", + "Notes that turbo must be invoked through `gtb turbo`, whose PATH normalization keeps the shim reachable once it occupies pnpm's relative `./node_modules/.bin` entry" ], - "expected_output": "Activates skill. Explains that the Linux turbo binary is glibc-built but Termux is Bionic; Termux's `LD_PRELOAD=libtermux-exec-ld-preload.so` rewrites `/usr/bin/env` shebangs in `execve` syscalls, but the preload is Bionic-only and doesn't load into glibc turbo, so the kernel sees `#!/usr/bin/env node` and fails because `/usr/bin/env` doesn't exist. Fix: add `@gtbuchanan/pnpm-termux-shim` as an `optionalDependencies` entry on the workspace root so its absolute-path `bin/pnpm` lands in `/node_modules/.bin/pnpm` ahead of the system pnpm.", + "expected_output": "Activates skill. Explains that Termux has no `/usr/bin/env`, so pnpm's `#!/usr/bin/env node` shebang fails on `execve`; Termux's `LD_PRELOAD=libtermux-exec-ld-preload.so` normally rewrites those, but it's a dynamic-loader feature and the npm turbo is statically linked, so it never loads. Fix: add `@gtbuchanan/pnpm-termux-shim` as an `optionalDependencies` entry on the workspace root so its absolute-path `bin/pnpm` lands in `/node_modules/.bin/pnpm`, and invoke turbo through `gtb turbo` so PATH entries are absolute — otherwise turbo matches the shim via pnpm's relative `./node_modules/.bin` and loses it when each task spawns from its own package directory.", "files": [], "id": 3, "prompt": "On Termux turbo fails to spawn pnpm with ENOENT. What's going on and how do I fix it?" diff --git a/packages/cli/src/commands/root/turbo.ts b/packages/cli/src/commands/root/turbo.ts index 8912298f..0bac2177 100644 --- a/packages/cli/src/commands/root/turbo.ts +++ b/packages/cli/src/commands/root/turbo.ts @@ -1,102 +1,60 @@ -import { existsSync } from 'node:fs'; import path from 'node:path'; import { defineCommand } from 'citty'; import { run } from '../../lib/process.ts'; import { rootNames } from './names.ts'; -/** - * Setup help shown when the global turbo binary is missing on Android. - * Termux ships a native turbo via its package registry; the npm - * `@turbo/linux-` workaround is no longer needed and the - * launcher in `node_modules/.bin/turbo` rejects - * `process.platform === 'android'` upfront. - * - * Native android binaries are not coming from upstream — vercel/turborepo#5616 - * was closed as "not planned" — so the Termux-pkg turbo (Bionic-built - * against `aarch64-linux-android`) is the supported path. - */ -const androidSetupHelp = ` -gtb turbo: the global turbo binary is not installed. - -On Android (Termux), gtb turbo execs the native turbo from the Termux -package registry instead of the npm-distributed Linux binary. The -node_modules launcher refuses to start when process.platform === 'android'. - -Install it from the Termux registry: - - pkg install turbo - -If your Termux prefix is non-standard, set $PREFIX before running. -`.trimStart(); +/** Locates the PATH variable, tolerating Windows' `Path` casing. */ +const findPathKey = (env: NodeJS.ProcessEnv): string | undefined => + Object.keys(env).find(key => key.toUpperCase() === 'PATH'); /** - * Resolves the global Termux-pkg-installed turbo binary. Honors - * Termux's $PREFIX env var; falls back to the standard install path - * when $PREFIX is unset (matching the convention used by - * `@gtbuchanan/pnpm-termux-shim`). - */ -const resolveAndroidTurboBinary = (): string | undefined => { - const prefix = process.env['PREFIX'] ?? '/data/data/com.termux/files/usr'; - const candidate = path.join(prefix, 'bin', 'turbo'); - return existsSync(candidate) ? candidate : undefined; -}; - -/** Discriminated plan for how to invoke turbo from the current host. */ -export type TurboInvocation = - | { readonly kind: 'error'; readonly message: string } - | { readonly kind: 'spawn'; readonly args: readonly string[]; readonly bin: string }; - -/** Inputs to {@link planTurboInvocation}. */ -export interface PlanTurboInvocationOptions { - readonly platform: string; - readonly rawArgs: readonly string[]; - readonly resolveAndroidBinary?: () => string | undefined; -} - -/** - * Computes the turbo invocation plan for a given host. On Android - * resolves the Termux-pkg turbo binary directly (bypassing the - * node_modules launcher, which rejects `android` upfront). On every - * other platform delegates to the launcher on PATH so its native - * install behavior is preserved. + * Returns `env` with every PATH entry rewritten to an absolute path, + * resolved against `cwd`. + * + * Turbo resolves the package manager binary against PATH once, from the + * directory it was invoked in, and keeps whatever path that search + * produced — then spawns each task with its own package directory as + * the cwd. A match from a relative entry therefore yields a relative + * program path, which the child re-resolves against its own directory: + * it either misses entirely (ENOENT, with no PATH search to fall back + * on, since the path now contains a separator) or, if the same relative + * directory happens to exist there, runs a different binary. + * + * pnpm always prepends a relative `./node_modules/.bin`, so this bites + * whenever a bin named after the package manager lives there. On Termux + * that is exactly the layout `@gtbuchanan/pnpm-termux-shim` creates, and + * every non-root task fails without this normalization. * - * The Termux-pkg turbo is Bionic-built, so its child-process spawns - * honor Termux's `LD_PRELOAD` shebang rewriter and resolve - * `#!/usr/bin/env ` correctly. The companion - * `@gtbuchanan/pnpm-termux-shim` package is retained defensively in - * case turbo reintroduces a glibc npm distribution, or another glibc - * binary in the graph needs to spawn `pnpm`. + * Rewriting is unconditional: a relative PATH entry is cwd-sensitive on + * every platform, and pinning it to the invoking directory is what the + * entry already meant when PATH was assembled. */ -export const planTurboInvocation = ( - options: PlanTurboInvocationOptions, -): TurboInvocation => { - if (options.platform !== 'android') { - return { args: [...options.rawArgs], bin: 'turbo', kind: 'spawn' }; - } - const resolveBin = options.resolveAndroidBinary ?? resolveAndroidTurboBinary; - const resolved = resolveBin(); - if (resolved === undefined) { - return { kind: 'error', message: androidSetupHelp }; - } - return { args: [...options.rawArgs], bin: resolved, kind: 'spawn' }; +export const withAbsolutePathEntries = ( + env: NodeJS.ProcessEnv, + cwd: string, +): NodeJS.ProcessEnv => { + const key = findPathKey(env); + if (key === undefined) return env; + + return { + ...env, + [key]: (env[key] ?? '') + .split(path.delimiter) + .map(entry => (path.isAbsolute(entry) ? entry : path.resolve(cwd, entry))) + .join(path.delimiter), + }; }; -/** `gtb turbo` — runs turbo, with an Android (Termux) escape hatch. */ +/** `gtb turbo` — runs turbo with cwd-independent PATH entries. */ export const turbo = defineCommand({ meta: { - description: 'Run turbo (with an Android escape hatch)', + description: 'Run turbo with cwd-independent PATH entries', name: rootNames.turbo, }, run: async ({ rawArgs }) => { - const plan = planTurboInvocation({ - platform: process.platform, - rawArgs, + await run('turbo', { + args: rawArgs, + env: withAbsolutePathEntries(process.env, process.cwd()), }); - if (plan.kind === 'error') { - console.error(plan.message); - process.exitCode = 1; - return; - } - await run(plan.bin, { args: plan.args }); }, }); diff --git a/packages/cli/src/lib/turbo-scripts.ts b/packages/cli/src/lib/turbo-scripts.ts index c3edaef2..ebd10469 100644 --- a/packages/cli/src/lib/turbo-scripts.ts +++ b/packages/cli/src/lib/turbo-scripts.ts @@ -141,9 +141,9 @@ const requiredRootScripts = ( * repos get none. * * Aliases route through `gtb turbo` rather than `turbo` directly so the - * Android escape hatch (resolve Linux platform binary, bypass turbo's - * launcher) activates transparently. On every other platform `gtb turbo` - * is a thin pass-through, so this does not change behavior. + * PATH normalization it applies (absolute entries only, so turbo's + * package-manager lookup survives the cwd change it makes per task) + * covers every alias. */ const aliasRootScriptEntries = ( flags: ToolFlags, diff --git a/packages/cli/test/turbo.test.ts b/packages/cli/test/turbo.test.ts index 5301c138..3c2c579b 100644 --- a/packages/cli/test/turbo.test.ts +++ b/packages/cli/test/turbo.test.ts @@ -1,146 +1,75 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, it, vi } from 'vitest'; -import { type TurboInvocation, planTurboInvocation } from '#src/commands/root/turbo.js'; - -interface PrefixFixture { - readonly prefix: string; - readonly turboBin: string; - readonly [Symbol.dispose]: () => void; -} - -const createPrefixFixture = (): PrefixFixture => { - const prefix = mkdtempSync(path.join(tmpdir(), 'gtb-turbo-test-')); - return { - prefix, - turboBin: path.join(prefix, 'bin', 'turbo'), - [Symbol.dispose]: () => { - rmSync(prefix, { force: true, recursive: true }); - }, - }; -}; - -const baseOptions = { - rawArgs: ['run', 'build'] as const, -}; - -const stubResolver = (resolved?: string) => (): string | undefined => resolved; - -/** Narrows a {@link TurboInvocation} to the error variant or throws. */ -function assertErrorPlan( - plan: TurboInvocation, -): asserts plan is Extract { - if (plan.kind !== 'error') throw new Error(`expected error plan, got ${plan.kind}`); -} - -describe.concurrent(planTurboInvocation, () => { - it('delegates to turbo on PATH for non-android platforms', ({ expect }) => { - const plan = planTurboInvocation({ ...baseOptions, platform: 'linux' }); - - expect(plan).toStrictEqual({ - args: ['run', 'build'], - bin: 'turbo', - kind: 'spawn', - }); +import { describe, it } from 'vitest'; +import { withAbsolutePathEntries } from '#src/commands/root/turbo.js'; + +const cwd = path.resolve('/repo'); +const join = (...segments: readonly string[]): string => path.join(cwd, ...segments); +const entriesOf = (value: string | undefined): readonly string[] => + (value ?? '').split(path.delimiter); + +describe.concurrent(withAbsolutePathEntries, () => { + it('resolves a relative entry against the given directory', ({ expect }) => { + const env = withAbsolutePathEntries({ PATH: './node_modules/.bin' }, cwd); + + expect(env).toHaveProperty('PATH', join('node_modules', '.bin')); }); - it('does not invoke the resolver for darwin', ({ expect }) => { - let wasCalled = false; - const plan = planTurboInvocation({ - ...baseOptions, - platform: 'darwin', - resolveAndroidBinary: (): string | undefined => { - wasCalled = true; - return undefined; - }, - }); - - expect(plan.kind).toBe('spawn'); - expect(wasCalled).toBe(false); + it('leaves absolute entries untouched and preserves order', ({ expect }) => { + const usrBin = path.resolve('/usr/bin'); + const value = ['./node_modules/.bin', usrBin].join(path.delimiter); + + const env = withAbsolutePathEntries({ PATH: value }, cwd); + + expect(entriesOf(env['PATH'])).toStrictEqual([join('node_modules', '.bin'), usrBin]); }); - it('does not invoke the resolver for win32', ({ expect }) => { - let wasCalled = false; - const plan = planTurboInvocation({ - ...baseOptions, - platform: 'win32', - resolveAndroidBinary: (): string | undefined => { - wasCalled = true; - return undefined; - }, - }); - - expect(plan.kind).toBe('spawn'); - expect(wasCalled).toBe(false); + it('resolves parent-relative entries', ({ expect }) => { + const env = withAbsolutePathEntries({ PATH: '../shared/bin' }, cwd); + + expect(env).toHaveProperty('PATH', path.resolve(cwd, '..', 'shared', 'bin')); }); - it('uses the resolved global turbo binary on android', ({ expect }) => { - const plan = planTurboInvocation({ - ...baseOptions, - platform: 'android', - resolveAndroidBinary: stubResolver( - '/data/data/com.termux/files/usr/bin/turbo', - ), - }); - - expect(plan).toStrictEqual({ - args: ['run', 'build'], - bin: '/data/data/com.termux/files/usr/bin/turbo', - kind: 'spawn', - }); + /* + * POSIX reads an empty entry as the current directory, which carries + * the same cwd-sensitivity as `./x` — so it resolves too. + */ + it('resolves an empty entry to the given directory', ({ expect }) => { + const usrBin = path.resolve('/usr/bin'); + + const env = withAbsolutePathEntries({ PATH: `${usrBin}${path.delimiter}` }, cwd); + + expect(entriesOf(env['PATH'])).toStrictEqual([usrBin, cwd]); }); - it('forwards raw args verbatim to the spawn plan', ({ expect }) => { - const plan = planTurboInvocation({ - ...baseOptions, - platform: 'android', - rawArgs: ['run', 'build', '--filter=@scope/pkg', '--concurrency=1'], - resolveAndroidBinary: stubResolver('/fake/bin/turbo'), - }); - - expect(plan).toMatchObject({ - args: ['run', 'build', '--filter=@scope/pkg', '--concurrency=1'], - kind: 'spawn', - }); + it('preserves other environment variables', ({ expect }) => { + const env = withAbsolutePathEntries({ PATH: './bin', TURBO_TELEMETRY_DISABLED: '1' }, cwd); + + expect(env).toHaveProperty('TURBO_TELEMETRY_DISABLED', '1'); }); - it('returns an error plan when the global turbo is missing on android', ({ expect }) => { - const plan = planTurboInvocation({ - ...baseOptions, - platform: 'android', - resolveAndroidBinary: stubResolver(), - }); - assertErrorPlan(plan); + it('returns the environment unchanged when no PATH is set', ({ expect }) => { + const env = withAbsolutePathEntries({ HOME: '/home/user' }, cwd); - expect(plan.message).toContain('pkg install turbo'); - expect(plan.message).toContain('global turbo binary is not installed'); + expect(env).toStrictEqual({ HOME: '/home/user' }); }); -}); -/* - * Default-resolver tests stub $PREFIX, which is global state, so they - * run serially (no `.concurrent`) and rely on vitest's `unstubEnvs` - * setting to restore the env between cases. - */ -describe('planTurboInvocation default android resolver', () => { - it('resolves $PREFIX/bin/turbo when the binary exists', ({ expect }) => { - using fixture = createPrefixFixture(); - mkdirSync(path.dirname(fixture.turboBin), { recursive: true }); - writeFileSync(fixture.turboBin, ''); - vi.stubEnv('PREFIX', fixture.prefix); - - const plan = planTurboInvocation({ ...baseOptions, platform: 'android' }); - - expect(plan).toMatchObject({ bin: fixture.turboBin, kind: 'spawn' }); + /* + * Windows spells the variable `Path`. Writing a second `PATH` key + * would leave the child with two competing definitions, so the + * original casing is rewritten in place. + */ + it('rewrites the existing key casing rather than adding a duplicate', ({ expect }) => { + const env = withAbsolutePathEntries({ Path: './bin' }, cwd); + + expect(Object.keys(env)).toStrictEqual(['Path']); + expect(env).toHaveProperty('Path', join('bin')); }); - it('returns an error plan when $PREFIX/bin/turbo is missing', ({ expect }) => { - using fixture = createPrefixFixture(); - vi.stubEnv('PREFIX', fixture.prefix); + it('does not mutate the source environment', ({ expect }) => { + const source = { PATH: './bin' }; - const plan = planTurboInvocation({ ...baseOptions, platform: 'android' }); + withAbsolutePathEntries(source, cwd); - expect(plan.kind).toBe('error'); + expect(source).toStrictEqual({ PATH: './bin' }); }); }); diff --git a/packages/pnpm-termux-shim/README.md b/packages/pnpm-termux-shim/README.md index 3c02b466..c98f5df1 100644 --- a/packages/pnpm-termux-shim/README.md +++ b/packages/pnpm-termux-shim/README.md @@ -12,10 +12,11 @@ ENOENT. Termux ships an `LD_PRELOAD` (`libtermux-exec-ld-preload.so`) that rewrites these shebangs in libc's `execve` to point at Termux's actual -paths. That fix is Bionic-only: it never loads into glibc-built binaries -like the upstream Linux turbo distribution. So when turbo (or any other -glibc binary) spawns `pnpm`, the rewriting doesn't happen and the kernel -sees the literal shebang path. ENOENT. +paths. `LD_PRELOAD` is a dynamic-loader feature, so it never reaches a +binary the loader isn't involved in — including the upstream Linux +turbo, which ships statically linked (no `PT_INTERP`). So when turbo (or +any other such binary) spawns `pnpm`, the rewriting doesn't happen and +the kernel sees the literal shebang path. ENOENT. The fix is a `pnpm` shim whose own shebang is an absolute path (`#!/data/data/com.termux/files/usr/bin/bash`). The kernel resolves it @@ -34,15 +35,19 @@ the dev workspace doesn't trip pnpm's "unsupported platform" warning during dogfooding. The published tarball still ships with `"os": ["android"]` at the top level. -See [vercel/turborepo#5616](https://github.com/vercel/turborepo/issues/5616) -for the upstream context: native Android support for turbo was declined, -so consumers run the linux-arm64 binary on Termux and work around the -spawn behavior with shims like this one. +Upstream context: +[vercel/turborepo#5616](https://github.com/vercel/turborepo/issues/5616) +asked for Android support and was declined in 2023; +[vercel/turborepo#12735](https://github.com/vercel/turborepo/pull/12735) +reversed that in turbo 2.10.8 by publishing the `linux-arm64` binary +under `os: ["android", "linux"]`. That makes the npm launcher start on +Termux — it does not change how the binary spawns children, which is +what this shim addresses. ## Usage Add to `optionalDependencies` of any package whose graph spawns `pnpm` -via a glibc binary on Termux: +from a binary the Termux `LD_PRELOAD` can't reach: ```jsonc { @@ -57,6 +62,15 @@ shadows the broken system `pnpm` for any subprocess that resolves via the project's bin directory. Everywhere else, the dependency is filtered out and nothing is installed. +One caveat for the process doing the resolving: it must reach the shim +through an **absolute** PATH entry. pnpm prepends a relative +`./node_modules/.bin`, and a consumer that resolves a binary from one +directory but spawns it from another then gets ENOENT — turbo does +exactly this, resolving the package manager at the repo root and +spawning each task in its package directory. `gtb turbo` from +[`@gtbuchanan/cli`](https://github.com/gtbuchanan/tooling/tree/main/packages/cli) +absolutizes PATH before invoking turbo for this reason. + The wrapper honors `$PREFIX` (Termux's standard prefix env var), so non-default Termux install layouts still work; if `$PREFIX` is unset the wrapper falls back to the standard `/data/data/com.termux/files/usr`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82924f09..eec4b34a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@faker-js/faker': specifier: ^10.4.0 version: 10.5.0 + '@gtbuchanan/pnpm-termux-shim': + specifier: ^0.1.1 + version: 0.1.1 '@pnpm/lockfile.types': specifier: ^1100.0.5 version: 1100.0.16 @@ -145,8 +148,8 @@ catalogs: specifier: ^1.7.0 version: 1.7.1 turbo: - specifier: ^2.5.4 - version: 2.10.7 + specifier: ^2.10.8 + version: 2.10.8 typescript: specifier: ^6.0.0 version: 6.0.3 @@ -225,7 +228,7 @@ importers: version: 1.2.0 turbo: specifier: 'catalog:' - version: 2.10.7 + version: 2.10.8 typescript: specifier: 'catalog:' version: 6.0.3 @@ -235,6 +238,10 @@ importers: vitest: specifier: 'catalog:' version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.9.0)) + optionalDependencies: + '@gtbuchanan/pnpm-termux-shim': + specifier: 'catalog:' + version: 0.1.1 packages/cli: dependencies: @@ -766,6 +773,12 @@ packages: resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@gtbuchanan/pnpm-termux-shim@0.1.1': + resolution: {integrity: sha512-2bzqtt5//VUpP6Ln+9QqyE/HW4xn1juJmEtpXJziwxsF+VWFQPteURMIQJptT2zQFAOw40KCblMkztYbmbMpuA==} + engines: {node: '>=24.0.0'} + os: [android] + hasBin: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1121,33 +1134,33 @@ packages: '@tsconfig/strictest@2.0.8': resolution: {integrity: sha512-XnQ7vNz5HRN0r88GYf1J9JJjqtZPiHt2woGJOo2dYqyHGGcd6OLGqSlBB6p1j9mpzja6Oe5BoPqWmeDx6X9rLw==} - '@turbo/darwin-64@2.10.7': - resolution: {integrity: sha512-/c9cSBRermWDv85oufLhoH6XRLOVbvzJLRd+WLyfJCP+i0HFLQj4PVNDrHcY17/ve5l8X0Oua4bJBqJUgJPnZA==} + '@turbo/darwin-64@2.10.8': + resolution: {integrity: sha512-po+7rfJfUnFXjWlcoN2RwhErgzCdRtBc1T26vYPcywHlggmCQiQe1uWaE4j+BibI2uY9/2pDoFzMN0rmSaPFOw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.10.7': - resolution: {integrity: sha512-8lpCCGWZBl9PIF8w8f2iEWrLMbHBWIfJeV6l2UEGqysD6HRIM4ySj/8R7HGEzbECJ4r/gnJcHmxEoG8yjFe64A==} + '@turbo/darwin-arm64@2.10.8': + resolution: {integrity: sha512-+zB2btDJ00lnPRuqOvpVvgl4x34k/djZQGZTTCfjn7JgNCl8QFY5Njo5+dqkY1g/+9gbbsnAvWm9CmJg9ebcXA==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.10.7': - resolution: {integrity: sha512-Midw9Ed00yw9rqkWN82fY3LmLNFQ6yiL1GXB56DJKUEjWaEd27zh7ohCxzzrjfjQVrEeVWd3UPytTAV16XDQlA==} + '@turbo/linux-64@2.10.8': + resolution: {integrity: sha512-K1dxqiVisyN7cViVsfQLs6xscQbYuI8aO2nbUhFURDACgEDfZRdP/b4CCxeosBJpcMfhYyiibWqJorCnvz9kKg==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.10.7': - resolution: {integrity: sha512-UVEy+MW/xn4BcsiV3v3uv0/oObyaQgVtRT+Jj4WE53rNH05VEYEc1Z13q3zV6276wCZR4Yxc4hiTTcjw7PjSpg==} + '@turbo/linux-arm64@2.10.8': + resolution: {integrity: sha512-Gi77ibVnrE1fEmvr+/wBD/yvRqhwp/RQuCp2+//lv1U1wNFFyVg0V7Wj8FG9FXPFAw5QHReo8rxc9+wBSDZjzA==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.10.7': - resolution: {integrity: sha512-l2nH9KGLV46SWjcXvyc2+xo5gdf5J0NVADknQk9OCJhzJBpiNl/byd26yIfwZBjLupdqT6UOdPhPxcpUPxRykQ==} + '@turbo/windows-64@2.10.8': + resolution: {integrity: sha512-znnLO1haJPYTHoKMKwlAvlkjRiYbbhBzME6wIGaMd+fwir23U6jVd1ecaTWWi1fbnRVqxMfgDBKseQ/hLKb83g==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.10.7': - resolution: {integrity: sha512-qjE1apG6RThuX49vUJd5ks2dV2ndXC2qktDChQonFMvUzrMrEnZMQzO+IgxBiYq1j+NbXQcRwj84nGnk1TBw1g==} + '@turbo/windows-arm64@2.10.8': + resolution: {integrity: sha512-VN30vh3b3Czh2WzYHNTfF1FE0YMZ5aHsLO8dBMGHJewA6792wX6iJR8ZxlzFW6WdOu0gEAKIvlYhfyT81Wkm4Q==} cpu: [arm64] os: [win32] @@ -2870,8 +2883,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo@2.10.7: - resolution: {integrity: sha512-GHx6WExIFSKNJ5qMlzDpXBXlu9ApxaMjqxAVrCNcW94xf/+uqgIz41SAuRUMbXva2ExNAaY/h8V0q90SWSzmRw==} + turbo@2.10.8: + resolution: {integrity: sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg==} hasBin: true type-check@0.4.0: @@ -3412,6 +3425,9 @@ snapshots: '@faker-js/faker@10.5.0': {} + '@gtbuchanan/pnpm-termux-shim@0.1.1': + optional: true + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3656,22 +3672,22 @@ snapshots: '@tsconfig/strictest@2.0.8': {} - '@turbo/darwin-64@2.10.7': + '@turbo/darwin-64@2.10.8': optional: true - '@turbo/darwin-arm64@2.10.7': + '@turbo/darwin-arm64@2.10.8': optional: true - '@turbo/linux-64@2.10.7': + '@turbo/linux-64@2.10.8': optional: true - '@turbo/linux-arm64@2.10.7': + '@turbo/linux-arm64@2.10.8': optional: true - '@turbo/windows-64@2.10.7': + '@turbo/windows-64@2.10.8': optional: true - '@turbo/windows-arm64@2.10.7': + '@turbo/windows-arm64@2.10.8': optional: true '@tybys/wasm-util@0.10.3': @@ -5528,14 +5544,14 @@ snapshots: tslib@2.8.1: optional: true - turbo@2.10.7: + turbo@2.10.8: optionalDependencies: - '@turbo/darwin-64': 2.10.7 - '@turbo/darwin-arm64': 2.10.7 - '@turbo/linux-64': 2.10.7 - '@turbo/linux-arm64': 2.10.7 - '@turbo/windows-64': 2.10.7 - '@turbo/windows-arm64': 2.10.7 + '@turbo/darwin-64': 2.10.8 + '@turbo/darwin-arm64': 2.10.8 + '@turbo/linux-64': 2.10.8 + '@turbo/linux-arm64': 2.10.8 + '@turbo/windows-64': 2.10.8 + '@turbo/windows-arm64': 2.10.8 type-check@0.4.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7be05b3c..b64b3c01 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: '@eslint/json': ^2.0.1 '@eslint/markdown': ^8.0.3 '@faker-js/faker': ^10.4.0 + '@gtbuchanan/pnpm-termux-shim': ^0.1.1 '@pnpm/lockfile.types': ^1100.0.5 '@prettier/plugin-xml': ^3.4.2 '@stylistic/eslint-plugin': ^5.10.0 @@ -49,7 +50,7 @@ catalog: skills: 1.5.20 skills-npm: 1.2.0 smol-toml: ^1.7.0 - turbo: ^2.5.4 + turbo: ^2.10.8 typescript: ^6.0.0 typescript-eslint: ^8.64.0 valibot: ^1.4.2