@vzn/vx-migrate 0.0.645 → 0.0.646

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,10 +10,10 @@ Everything for adopting [`@vzn/vx`](https://vznjs.github.io/vx/) from Turborepo,
10
10
  - **`turboCache()`** and **`nxCache()`** — keep the remote cache you have: any server speaking Turbo's `/v8/artifacts` API (Vercel's hosted cache included) or Nx's self-hosted `/v1/cache` spec.
11
11
 
12
12
  ```sh
13
- npm install -D @vzn/vx @vzn/vx-migrate # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
13
+ bun add -d @vzn/vx @vzn/vx-migrate
14
14
  ```
15
15
 
16
- In a Turbo or Nx repo, `npx @vzn/vx init --keep` runs this package, which
16
+ In a Turbo or Nx repo, `bunx @vzn/vx init --keep` runs this package, which
17
17
  writes the `.vx/workspace.ts` that declares `turbo()` or `nx()`, installs
18
18
  what it imports and writes the lock. Where the repo shows a remote cache it
19
19
  declares `turboCache()` or `nxCache()` too; by hand it reads:
@@ -66,8 +66,8 @@ Rules:
66
66
  - `env` / `passThroughEnv`: explicit names go to `inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once. A name that is no shell variable name (`p-q`) is keyed but left out of `exec.env.passThrough`, as `sh` drops it before the task runs.
67
67
  - Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
68
68
 
69
- - **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. A keeper whose output is a directory at any depth (solid's build: `**/dist`) meets a sibling's tree (`coverage/**`) only at a `dist` inside it: the keeper takes that tree back with `!coverage/**` and both keep their cache, as under Turbo. Same rule for Nx targets.
70
- - **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): `vx lock` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `nx-input:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
69
+ - **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. When either side is a directory at any depth (solid's build `**/dist`, its types `**/types`), the two meet only where one tree nests in the other: the keeper takes the sibling's glob back (`!coverage/**`, `!**/types`) and both keep their cache, as under Turbo. Same rule for Nx targets.
70
+ - **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): `vx lock` refuses it, since such a key cannot be known before the producer ran. Each overlapping output is taken back from the reader's inputs with `!` (`['**/*', '!dist/**']`), in package and workspace inputs alike, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `inputs:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
71
71
  - **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
72
72
  - `cache: false` or `persistent: true` → no `cache` block, the task still keyed on its `inputs` and `env` as Turbo hashes it; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
73
73
  - A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
@@ -91,7 +91,7 @@ import { nx } from '@vzn/vx-migrate'
91
91
  export default defineWorkspace({ plugins: [nx()] })
92
92
  ```
93
93
 
94
- Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every project's `build` target the way `nx run-many -t build` would. `nx()` is also the repo's edge source: each project's dependencies on Nx's graph (package deps, `implicitDependencies`, imports) are its `deps`, so `^build` follows Nx's order with no lockfile plugin. The plugin reads Nx's **resolved** project graph — where Nx has already applied `targetDefaults`, expanded `namedInputs`, inferred targets through its plugins and interpolated `{projectRoot}` and friends — so nothing is re-derived here. Per target: `nx:run-commands` and a plain `command` run as one shell line that does what Nx's run-commands does (below); `nx:run-script` is the package script, with the `$npm_package_name`, `$npm_package_version` and `$npm_lifecycle_event` it reads defined as `<pm> run` sets them (a migration reads the name and version from the manifest it imports, so a bump reaches them; any other `$npm_*` is a todo); `nx:noop` is a group task, and so is a target with neither an executor nor a command that has dependencies, as Nx normalizes it (one with none is dropped, as Nx drops it); **every other executor runs through `nx-exec`** with the executor and its options on the command line. Every target is keyed on its `inputs` (a target Nx does not cache on its own inputs alone, no `^` named inputs, since Nx folds no dependency's hash into it), and a target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `outputs` as the cache block, with no `inputs` meaning Nx's `default` and `^default`, named inputs resolved per project (nx.json's under the project's own; one neither defines, such as an `extends` preset not installed, keys the whole project with a todo, never an empty list that keys the task on its config alone), `{workspaceRoot}` / `{projectRoot}` / `{projectName}` interpolated anywhere in a path as Nx does (`{workspaceRoot}/coverage/{projectRoot}`), Nx's glob grammar translated (`*.[jt]s` is `*.{j,t}s`; the default `production` negation `?(*.)+(spec|test).[jt]s?(x)` becomes brace sets, a narrowing only a negation may take; what has no safe form is a todo), a project fileset of negations alone (nx-recipes' `noMarkdown`) starting from every project file, as Nx reads it, and an output outside the project dir (`dist/<project>` at the workspace root, Nx's default layout) a `workspaceFiles` output, an output outside the workspace (an old generator's `reportsDirectory: "../../coverage/<lib>"`) a todo and dropped, as vx caches only inside it, an output whose first segment is a wildcard (`{projectRoot}/**/*.d.ts`) cached, with the committed files it reaches kept (core never cleans one) and the rest cleaned before the run, and an output that covers the project's own `package.json` or vx.config cached with the file taken back by `!`; `dependsOn` becomes the edges (`^build` — dropped when no project has the target, as Nx gives it no edges and core refuses a `^name` nothing declares — `build`, `project:target` → `project#target`, everything past `project:` one target name, as Nx joins it (`ui:build:esm` is ui's `build:esm`; `ui:build:ci` names target `build:ci`, never `build`'s `ci` configuration, and is no edge where ui lacks it), this project's own target winning over a project name, `{ target, projects }` → each matched project's task, `projects` read as Nx's `findMatchingProjects` reads it: a name or a list of names, globs over names and over project directories (`libs/*`), `name:` / `tag:` / `directory:` labels, a bare word as a word in a name, and `!` exclusions; an edge to a target its project lacks is dropped without a word, as Nx drops it — `targetDefaults` that give every `typecheck` a `codegen` one project has; `^name` is every dependency's `name` whatever it holds, `^rsbuild:typecheck` included; a target glob — `test:e2e--*`, `^build-*`, `ui:build-{esm,cjs}`, a `{ target }` object's — expands over every target name in the workspace, as Nx 19.5+ does, before those rules; a same-project glob keeps its own project's matches), `{ env }` inputs pass through (one that is no shell variable name is keyed only), `{ runtime }` inputs run at the workspace root as Nx runs them (`inputs.workspaceRuntime`), with `{workspaceRoot}`, `{projectRoot}` and `{projectName}` written in as the paths and name they stand for (Nx runs the command as written, so `cat {workspaceRoot}/v.txt` keyed on cat's error there), and a command that exits non-zero keys its error and exit code and the task runs on, as Nx hashes its output whatever it exits (`git describe` in a repo with no tags): the written probe is `(<command>) || echo "exit $?"`, a `{ fileset, includeIgnored: true }` literal (Nx 23 hashes it from disk, gitignored or missing) is read by such a probe, since vx's globs see only what git lists, and a glob of one is a todo, a target that says `continuous` — or, in a graph from an Nx older than that field, runs a server executor (`@nx/vite:dev-server`, `@nx/next:server`, …) — is a persistent task and never cached, ready on spawn as Nx has it (Nx starts the dependents once it has started; a run-commands `readyWhen` gates them, below). The target's name never decides: a cached `dev` caches like any other target (nx#32610). `parallelism: false` (Nx runs the target alone) has no vx form and is a todo naming `--concurrency 1`; `syncGenerators` (Nx runs them first) is one workspace note per generator list, counting its tasks and naming `nx sync`; nx.json's `sync.globalGenerators` is one note per run naming `nx sync`. A target with `configurations` is one task per configuration (one whose name another target holds, `vite`'s `build` beside an inferred `vite:build`, is not written, with a todo, as Nx resolves `a:vite:build` to the target): `build` carries the default configuration's options, `build:ci` the `ci` one, and an edge of `build:ci` (own, named or `^`) reaches each target's `ci` task where it declares one, else its default, as Nx's `resolveConfiguration` does; a `^build` there becomes one edge per dependency Nx links. A `^name` input (`^production`, `{ input, dependencies: true }`, the pre-17 `{ input, projects: "dependencies" }`; `projects: "self"` is the own input, as Nx 23 still reads both) is what Nx hashes: each dependency's `name`, over the project graph, whether or not a task edge exists. A `{ input, projects }` list is read as `dependsOn`'s `projects` is. Each project some reader reaches gets an `nx-input:<name>` task — a keyed group (no command, nothing spawns, not counted in the run summary) keyed on its own `name` input, depending on its Nx dependencies' twins (a project nothing reaches gets none) — and a task that reads `^name` depends on its direct dependencies' twins, so the whole closure folds into its key with each project hashed once. A dependency FILESET (`^{projectRoot}/tsconfig.lib.json`, `{ fileset, dependencies: true }`, which Nx's own inferred `typecheck` writes) folds the same way: its twin, `nx-input:fileset-<hash>` (a glob's `*` is no task name; the task's description names the fileset), keys on that fileset in each project. A twin lists its project's `package.json` and `project.json` first (whichever exists; Nx keys every task on its project's manifest and configuration): a fileset that matches nothing (an absent `tsconfig.spec.json`, a dependency's unbuilt `*.d.ts`) is nothing to Nx and no "matched no files" warning, and the file appearing re-keys its readers. The twins spawn nothing and stay out of a run's output and task count, so `vx run build` counts the tasks `nx run-many -t build` does. A path input inside an output one of the project's own targets declares (TanStack/table's `public` input lists `{projectRoot}/dist`) is generated, and gitignored where Nx caches it: Nx's file map skips gitignored files, so Nx hashes nothing there (a changed `dist` file is a cache hit under Nx 23.3), and it is dropped without a todo, and the task that writes it keys its dependants through `dependsOn`.
94
+ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every project's `build` target the way `nx run-many -t build` would. `nx()` is also the repo's edge source: each project's dependencies on Nx's graph (package deps, `implicitDependencies`, imports) are its `deps`, so `^build` follows Nx's order with no lockfile plugin. The plugin reads Nx's **resolved** project graph — where Nx has already applied `targetDefaults`, expanded `namedInputs`, inferred targets through its plugins and interpolated `{projectRoot}` and friends — so nothing is re-derived here. Per target: `nx:run-commands` and a plain `command` run as one shell line that does what Nx's run-commands does (below); `nx:run-script` is the package script, with the `$npm_package_name`, `$npm_package_version` and `$npm_lifecycle_event` it reads defined as `<pm> run` sets them (a migration reads the name and version from the manifest it imports, so a bump reaches them; any other `$npm_*` is a todo); `nx:noop` is a group task, and so is a target with neither an executor nor a command that has dependencies, as Nx normalizes it (one with none is dropped, as Nx drops it); **every other executor runs through `nx-exec`** with the executor and its options on the command line. Every target is keyed on its `inputs` (a target Nx does not cache on its own inputs alone, no `^` named inputs, since Nx folds no dependency's hash into it), and a target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `outputs` as the cache block, with no `inputs` meaning Nx's `default` and `^default`, named inputs resolved per project (nx.json's under the project's own; one neither defines, such as an `extends` preset not installed, keys the whole project with a todo, never an empty list that keys the task on its config alone), `{workspaceRoot}` / `{projectRoot}` / `{projectName}` interpolated anywhere in a path as Nx does (`{workspaceRoot}/coverage/{projectRoot}`), Nx's glob grammar translated (`*.[jt]s` is `*.{j,t}s`; the default `production` negation `?(*.)+(spec|test).[jt]s?(x)` becomes brace sets, a narrowing only a negation may take; what has no safe form is a todo), a project fileset of negations alone (nx-recipes' `noMarkdown`) starting from every project file, as Nx reads it, and an output outside the project dir (`dist/<project>` at the workspace root, Nx's default layout) a `workspaceFiles` output, an output outside the workspace (an old generator's `reportsDirectory: "../../coverage/<lib>"`) a todo and dropped, as vx caches only inside it, an output whose first segment is a wildcard (`{projectRoot}/**/*.d.ts`) cached, with the committed files it reaches kept (core never cleans one) and the rest cleaned before the run, and an output that covers the project's own `package.json` or vx.config cached with the file taken back by `!`; `dependsOn` becomes the edges (`^build` — dropped when no project has the target, as Nx gives it no edges and core refuses a `^name` nothing declares — `build`, `project:target` → `project#target`, everything past `project:` one target name, as Nx joins it (`ui:build:esm` is ui's `build:esm`; `ui:build:ci` names target `build:ci`, never `build`'s `ci` configuration, and is no edge where ui lacks it), this project's own target winning over a project name, `{ target, projects }` → each matched project's task, `projects` read as Nx's `findMatchingProjects` reads it: a name or a list of names, globs over names and over project directories (`libs/*`), `name:` / `tag:` / `directory:` labels, a bare word as a word in a name, and `!` exclusions; an edge to a target its project lacks is dropped without a word, as Nx drops it — `targetDefaults` that give every `typecheck` a `codegen` one project has; `^name` is every dependency's `name` whatever it holds, `^rsbuild:typecheck` included; a target glob — `test:e2e--*`, `^build-*`, `ui:build-{esm,cjs}`, a `{ target }` object's — expands over every target name in the workspace, as Nx 19.5+ does, before those rules; a same-project glob keeps its own project's matches), `{ env }` inputs pass through (one that is no shell variable name is keyed only), `{ runtime }` inputs run at the workspace root as Nx runs them (`inputs.workspaceRuntime`), with `{workspaceRoot}`, `{projectRoot}` and `{projectName}` written in as the paths and name they stand for (Nx runs the command as written, so `cat {workspaceRoot}/v.txt` keyed on cat's error there), and a command that exits non-zero keys its error and exit code and the task runs on, as Nx hashes its output whatever it exits (`git describe` in a repo with no tags): the written probe is `(<command>) || echo "exit $?"`, a `{ fileset, includeIgnored: true }` literal (Nx 23 hashes it from disk, gitignored or missing) is read by such a probe, since vx's globs see only what git lists, and a glob of one is a todo, a target that says `continuous` — or, in a graph from an Nx older than that field, runs a server executor (`@nx/vite:dev-server`, `@nx/next:server`, …) — is a persistent task and never cached, ready on spawn as Nx has it (Nx starts the dependents once it has started; a run-commands `readyWhen` gates them, below). The target's name never decides: a cached `dev` caches like any other target (nx#32610). `parallelism: false` (Nx runs the target alone) has no vx form and is a todo naming `--concurrency 1`; `syncGenerators` (Nx runs them first) is one workspace note per generator list, counting its tasks and naming `nx sync`; nx.json's `sync.globalGenerators` is one note per run naming `nx sync`. A target with `configurations` is one task per configuration (one whose name another target holds, `vite`'s `build` beside an inferred `vite:build`, is not written, with a todo, as Nx resolves `a:vite:build` to the target): `build` carries the default configuration's options, `build:ci` the `ci` one, and an edge of `build:ci` (own, named or `^`) reaches each target's `ci` task where it declares one, else its default, as Nx's `resolveConfiguration` does; a `^build` there becomes one edge per dependency Nx links. A `^name` input (`^production`, `{ input, dependencies: true }`, the pre-17 `{ input, projects: "dependencies" }`; `projects: "self"` is the own input, as Nx 23 still reads both) is what Nx hashes: each dependency's `name`, over the project graph, whether or not a task edge exists. A `{ input, projects }` list is read as `dependsOn`'s `projects` is. Each project some reader reaches gets an `inputs:<name>` task — a keyed group (no command, nothing spawns, not counted in the run summary) keyed on its own `name` input, depending on its Nx dependencies' twins (a project nothing reaches gets none) — and a task that reads `^name` depends on its direct dependencies' twins, so the whole closure folds into its key with each project hashed once. A dependency FILESET (`^{projectRoot}/tsconfig.lib.json`, `{ fileset, dependencies: true }`, which Nx's own inferred `typecheck` writes) folds the same way: its twin, `inputs:fileset-<hash>` (a glob's `*` is no task name; the task's description names the fileset), keys on that fileset in each project. A twin lists its project's `package.json` and `project.json` first (whichever exists; Nx keys every task on its project's manifest and configuration): a fileset that matches nothing (an absent `tsconfig.spec.json`, a dependency's unbuilt `*.d.ts`) is nothing to Nx and no "matched no files" warning, and the file appearing re-keys its readers. The twins spawn nothing and stay out of a run's output and task count, so `vx run build` counts the tasks `nx run-many -t build` does. A path input inside an output one of the project's own targets declares (TanStack/table's `public` input lists `{projectRoot}/dist`) is generated, and gitignored where Nx caches it: Nx's file map skips gitignored files, so Nx hashes nothing there (a changed `dist` file is a cache hit under Nx 23.3), and it is dropped without a todo, and the task that writes it keys its dependants through `dependsOn`.
95
95
 
96
96
  | Option | Meaning |
97
97
  | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -147,7 +147,7 @@ Nx loads a task's `.env` files into its environment — the project's before the
147
147
 
148
148
  ### The target a task sees
149
149
 
150
- Every task gets `NX_TASK_TARGET_PROJECT`, `NX_TASK_TARGET_TARGET`, `LERNA_PACKAGE_NAME` (the project, which Lerna on Nx's runner documents to scripts) and, where it runs a configuration, `NX_TASK_TARGET_CONFIGURATION` in `exec.env.define`, as Nx hands them to every task; a run-commands `env` wins. A package script's `nx exec -- <cmd>` reads them: without them it starts Nx's own task runner, which runs the target and its dependencies again.
150
+ A task whose command still runs Nx or Lerna (`nx-exec`, `nx-env`, `nx exec`, `lerna`) or names one of them gets `NX_TASK_TARGET_PROJECT`, `NX_TASK_TARGET_TARGET`, `LERNA_PACKAGE_NAME` (the project, which Lerna on Nx's runner documents to scripts) and, where it runs a configuration, `NX_TASK_TARGET_CONFIGURATION` in `exec.env.define`, as Nx hands them to every task; a run-commands `env` wins. Any other task gets none. Nx's own `npm:public` / `npm:private` tags are not written. A package script's `nx exec -- <cmd>` reads them: without them it starts Nx's own task runner, which runs the target and its dependencies again.
151
151
 
152
152
  ### Inputs that key nothing here
153
153
 
@@ -226,11 +226,11 @@ bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo(
226
226
  bunx @vzn/vx-migrate --help # the usage, exit 0
227
227
  ```
228
228
 
229
- It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works the same):
229
+ It is the one command a repo needs (`pbunx @vzn/vx-migrate` in a pnpm repo works the same):
230
230
 
231
231
  - In a Turbo or Nx repo, in a terminal, it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (a workspace file declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native. Elsewhere both are refused. A repo already set up (a workspace file declaring neither, and project configs) is said to be, exit 0, unless `--force`.
232
232
  - Native or keep, a remote cache the repo shows is declared: `turboCache()` for turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json` or a CI file (`.github/workflows/*`, `.gitlab-ci.yml`, `.circleci/config.yml`) setting `TURBO_TOKEN`; `nxCache()` for one setting `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. The report names the file that showed it. Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is named instead: vx does not speak its wire.
233
- - With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`pnpm()`, `bun()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file it writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
233
+ - With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`bun()`, `pnpm()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file it writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
234
234
  - It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, every `@vzn/vx-*` package the workspace file imports (one an earlier init wrote included), and `@vzn/vx-migrate` for keep or when a written task runs its `nx-exec` or `nx-env` bin (`pnpm add -D -w`, `yarn add -D -W` on Yarn 1 and without `-W` on Yarn 2+, `bun add -d`, `npm install -D`). A package the root both lists and has installed at vx-migrate's own version is left alone; any other is installed at that version, and so is every other `@vzn/vx-*` plugin the root lists from the registry (one install moves them together, as `vx upgrade` does). Native removes a listed `@vzn/vx-migrate` when nothing written needs it (no `nx-exec` / `nx-env` task, no `turboCache()` or `nxCache()` left in the workspace file): `pnpm remove -w`, `yarn remove` (`-W` on Yarn 1), `bun remove`, `npm uninstall`. `--no-install` skips both. vx-migrate runs only with the `@vzn/vx` of its own release, which it installs: one of another release (a `bun x` install directory keeps the one it had) is refused, naming where it was loaded.
235
235
  - Native, once the files are written, it ends with `vx lock`: `.vx/projects.lock.json` is written and the graph rules are checked, its line the report's `lock` row. A refusal prints above the report and the configs stay written (exit 0). `--no-install` skips it, since the plugins the workspace file declares are not installed yet, and `next` then starts with `vx lock`.
236
236
 
@@ -284,7 +284,7 @@ Detected when the root `package.json` lists `vite-plus`, after turbo.json and Nx
284
284
  Where no runner's config is the source (or under `--from scripts`), each
285
285
  package's `package.json` scripts become its `vx.config.ts`, plus a
286
286
  `.vx/workspace.ts` that declares the `@vzn/vx-lockfile` plugin for the lockfile at
287
- the root (`pnpm()`, `bun()`, `npm()` or `yarn()`; `{ plugins: [] }`
287
+ the root (`bun()`, `pnpm()`, `npm()` or `yarn()`; `{ plugins: [] }`
288
288
  without one): package edges (`^build`, `--affected`) come from it.
289
289
  Running and caching here are the floor, so it declares no executor or
290
290
  cache, and the file carries no comment (it is the user's). When
@@ -497,11 +497,8 @@ order may matter, so it is not made a group.
497
497
  The report lists each TODO once per reason: tasks that share one are
498
498
  named together (the first five, then a count; the files carry each),
499
499
  and when no task caches it says uncommenting a TODO task's cache line
500
- makes the second run a hit. Its `next:` line is a command the user can type: the
501
- runner that started vx (`npx`, `pnpm`, `yarn`, `bunx`, read from
502
- `npm_config_user_agent`) with the installed `vx` bin, else the
503
- `@vzn/vx` package (`npx` under Yarn 1, which has no `dlx`); with no
504
- runner, a bare `vx`. Outside a git work tree it starts with
500
+ makes the second run a hit. Its `next:` line is a command the user can type,
501
+ with a bare `vx` (`vx init` installs it). Outside a git work tree it starts with
505
502
  `git init`, and with no script mapped with declaring a task, since the
506
503
  run would refuse without either. It runs `build`, else the first task that
507
504
  neither serves (`persistent`) nor changes the repo (`clean`, `release`,
@@ -550,7 +547,7 @@ export default defineWorkspace({
550
547
 
551
548
  Off CI a run reads the remote and does not write it unless its policy names the remote. Give the write token only to the default branch's CI and a read-only one to laptops and pull request jobs: the server's token is the boundary (`docs/security.md` § Cache poisoning). The same holds for `nxCache()`.
552
549
 
553
- Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. A token with no `apiUrl` means Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as it does for `turbo` — so `npx turbo login && npx turbo link`, then `turboCache()` with `TURBO_TOKEN` / `TURBO_TEAM` set, is the whole hosted setup. Below the environment, in Turbo's order: a Vercel build's `VERCEL_ARTIFACTS_TOKEN` and `VERCEL_ARTIFACTS_OWNER` (the team id, where `TURBO_TOKEN` with a team is not set), then the repo's `.turbo/config.json` (what `turbo link` writes: `apiUrl`, `teamId`, `teamSlug`, `token`), then the root `turbo.json`'s `remoteCache` (`apiUrl`, `teamId`, `teamSlug`), whose `enabled: false` declines unless the options name a cache. A team with no `apiUrl` also means Vercel's. With a URL or a team but no token, the token comes from the OS keychain ([Tokens](#tokens-and-the-os-keychain)); with none of the three the plugin **declines** and the run stays local.
550
+ Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. A token with no `apiUrl` means Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as it does for `turbo` — so `bunx turbo login && bunx turbo link`, then `turboCache()` with `TURBO_TOKEN` / `TURBO_TEAM` set, is the whole hosted setup. Below the environment, in Turbo's order: a Vercel build's `VERCEL_ARTIFACTS_TOKEN` and `VERCEL_ARTIFACTS_OWNER` (the team id, where `TURBO_TOKEN` with a team is not set), then the repo's `.turbo/config.json` (what `turbo link` writes: `apiUrl`, `teamId`, `teamSlug`, `token`), then the root `turbo.json`'s `remoteCache` (`apiUrl`, `teamId`, `teamSlug`), whose `enabled: false` declines unless the options name a cache. A team with no `apiUrl` also means Vercel's. With a URL or a team but no token, the token comes from the OS keychain ([Tokens](#tokens-and-the-os-keychain)); with none of the three the plugin **declines** and the run stays local.
554
551
 
555
552
  | Option | Environment variable | Meaning |
556
553
  | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.645",
3
+ "version": "0.0.646",
4
4
  "license": "MIT",
5
5
  "description": "Adopt @vzn/vx from Turborepo, Nx or package.json scripts: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
6
  "keywords": [
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "peerDependencies": {
43
- "@vzn/vx": "^0.0.645"
43
+ "@vzn/vx": "^0.0.646"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
@@ -6,7 +6,7 @@
6
6
  // skeleton until item 592; what differs is only how the mapping is made.
7
7
 
8
8
  import { definePlugin, type ProjectHookContext, type VxPlugin } from '@vzn/vx'
9
- import { type AdoptionMapping, cachedMapping } from './mapping-cache.js'
9
+ import { type AdoptionMapping, cachedMapping, codeId } from './mapping-cache.js'
10
10
  import { warnGaps } from './plugin-gaps.js'
11
11
  import { headStamp, spareTrackedOutputs, trackedFiles } from './tracked-outputs.js'
12
12
 
@@ -76,18 +76,16 @@ export function adoptionPlugin(
76
76
  async project(config, ctx) {
77
77
  if (mappedFor !== ctx.projects) {
78
78
  mappedFor = ctx.projects
79
+ // The key's other reads overlap the plugin's own.
80
+ void codeId()
81
+ const stamp = headStamp(ctx.workspaceRoot)
79
82
  mapping = mapRun(ctx).then(async (r) =>
80
83
  r.spareTracked === true
81
- ? cachedMapping(
82
- ctx.cacheDir,
83
- r.name,
84
- [...r.reads, await headStamp(ctx.workspaceRoot)],
85
- async () => {
86
- let read: Promise<readonly string[] | null> | undefined
87
- const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
88
- return spare(ctx, await r.map(tracked), await tracked())
89
- },
90
- )
84
+ ? cachedMapping(ctx.cacheDir, r.name, [...r.reads, await stamp], async () => {
85
+ let read: Promise<readonly string[] | null> | undefined
86
+ const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
87
+ return spare(ctx, await r.map(tracked), await tracked())
88
+ })
91
89
  : cachedMapping(ctx.cacheDir, r.name, r.reads, () =>
92
90
  r.map(() => trackedFiles(ctx.workspaceRoot)),
93
91
  ),
package/src/index.ts CHANGED
@@ -2,14 +2,40 @@
2
2
  // runs unchanged) and the two remote caches speaking Turbo's and Nx's
3
3
  // wire. The CLI (`bunx @vzn/vx-migrate`) is `migrate.ts`; mappers and
4
4
  // cache clients are internal (1.0 freezes what this file exports).
5
- export { turbo, type TurboPluginOptions } from './turbo/index.js'
6
- export {
7
- nx,
8
- type NxExecutors,
9
- type NxExecutorTarget,
10
- type NxExecutorTranslation,
11
- type NxExecutorTranslator,
12
- type NxPluginOptions,
5
+ //
6
+ // Each factory loads its module on first call: a workspace evaluates this
7
+ // file on every run, and a Turbo repo paid ~4 ms per run transpiling nx()
8
+ // and both cache clients it never calls (solid, 2026-10-22).
9
+ import type { VxPlugin } from '@vzn/vx'
10
+ import type * as Turbo from './turbo/index.js'
11
+ import type { TurboPluginOptions } from './turbo/index.js'
12
+ import type { NxPluginOptions } from './nx/index.js'
13
+ import type { TurboCacheOptions } from './turbo-cache/index.js'
14
+ import type { NxCacheOptions } from './nx-cache/index.js'
15
+ import type * as Nx from './nx/index.js'
16
+ import type * as TurboCache from './turbo-cache/index.js'
17
+ import type * as NxCache from './nx-cache/index.js'
18
+
19
+ export type { TurboPluginOptions } from './turbo/index.js'
20
+ export type {
21
+ NxExecutors,
22
+ NxExecutorTarget,
23
+ NxExecutorTranslation,
24
+ NxExecutorTranslator,
25
+ NxPluginOptions,
13
26
  } from './nx/index.js'
14
- export { turboCache, type TurboCacheOptions } from './turbo-cache/index.js'
15
- export { nxCache, type NxCacheOptions } from './nx-cache/index.js'
27
+ export type { TurboCacheOptions } from './turbo-cache/index.js'
28
+ export type { NxCacheOptions } from './nx-cache/index.js'
29
+
30
+ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
31
+ return (require('./turbo/index.js') as typeof Turbo).turbo(options)
32
+ }
33
+ export function nx(options: NxPluginOptions = {}): VxPlugin {
34
+ return (require('./nx/index.js') as typeof Nx).nx(options)
35
+ }
36
+ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
37
+ return (require('./turbo-cache/index.js') as typeof TurboCache).turboCache(options)
38
+ }
39
+ export function nxCache(options: NxCacheOptions = {}): VxPlugin {
40
+ return (require('./nx-cache/index.js') as typeof NxCache).nxCache(options)
41
+ }
@@ -8,6 +8,7 @@
8
8
  // package's sources and the core helpers the mappers call: a new
9
9
  // `@vzn/vx-migrate` or `@vzn/vx` maps afresh.
10
10
 
11
+ import { statSync } from 'node:fs'
11
12
  import { mkdir, rename } from 'node:fs/promises'
12
13
  import path from 'node:path'
13
14
  import { foldScriptHooks, isLiteralPattern, outputsOverlap, VERSION } from '@vzn/vx'
@@ -30,8 +31,8 @@ export interface AdoptionMapping {
30
31
 
31
32
  let code: Promise<string> | undefined
32
33
 
33
- /** This package's sources and the core helpers the mappers call, hashed once per process. */
34
- function codeId(): Promise<string> {
34
+ /** This package's sources (by size and mtime) and the core helpers the mappers call, once per process. */
35
+ export function codeId(): Promise<string> {
35
36
  code ??= (async () => {
36
37
  const parts: string[] = [
37
38
  VERSION,
@@ -40,7 +41,13 @@ function codeId(): Promise<string> {
40
41
  const files: string[] = []
41
42
  for await (const f of new Bun.Glob('**/*.{ts,cjs}').scan(import.meta.dir)) files.push(f)
42
43
  files.sort()
43
- for (const f of files) parts.push(f, await Bun.file(path.join(import.meta.dir, f)).text())
44
+ // Size and mtime, not bytes: reading the 650 kB of sources cost ~10 ms
45
+ // on every run, more than the mapping hit it guards saves on a small
46
+ // repo (solid, 2026-10-22). An edit moves both.
47
+ for (const f of files) {
48
+ const s = statSync(path.join(import.meta.dir, f))
49
+ parts.push(f, `${s.size}:${s.mtimeMs}`)
50
+ }
44
51
  return hex(parts.join('\0'))
45
52
  })()
46
53
  return code
@@ -71,11 +78,12 @@ export async function cachedMapping(
71
78
  parts: readonly string[],
72
79
  map: () => Promise<AdoptionMapping>,
73
80
  ): Promise<AdoptionMapping> {
74
- const key = hex([await codeId(), ...parts].join('\0'))
75
81
  const file = path.join(cacheDir, `vx-migrate-${name}-mapping.json`)
76
- const held = (await Bun.file(file)
82
+ const reading = Bun.file(file)
77
83
  .json()
78
- .catch(() => null)) as Held | null
84
+ .catch(() => null) as Promise<Held | null>
85
+ const key = hex([await codeId(), ...parts].join('\0'))
86
+ const held = await reading
79
87
  if (held?.key === key) {
80
88
  return {
81
89
  byName: new Map(held.byName),
@@ -12,12 +12,8 @@ import {
12
12
  quoteTsLiteral as quote,
13
13
  } from '@vzn/vx'
14
14
  import type { PackageMeta } from './packages.js'
15
- import {
16
- mapTurboWorkspace,
17
- rootTaskProject,
18
- type TurboGlobal,
19
- type TurboMappedProject,
20
- } from './turbo/turbo-map.js'
15
+ import { mapTurboWorkspace, type TurboGlobal, type TurboMappedProject } from './turbo/turbo-map.js'
16
+ import { rootTaskProject } from './turbo/turbo-reads.js'
21
17
  import { relPosix } from './paths.js'
22
18
  import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
23
19
  import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
package/src/migrate.ts CHANGED
@@ -53,14 +53,13 @@ import {
53
53
  missingPackages,
54
54
  MODE_QUESTION,
55
55
  ownVersion,
56
- type PackageManager,
57
56
  packageManagerOf,
58
57
  parseModeAnswer,
59
58
  uninstall,
60
59
  } from './adopt.js'
61
60
  import { migrateTurbo } from './migrate-turbo.js'
62
61
  import { migrateViteTask } from './migrate-vite-task.js'
63
- import { turboConfigFile } from './turbo/turbo-map.js'
62
+ import { turboConfigFile } from './turbo/turbo-reads.js'
64
63
  import { lockfilePlugin } from './workspace-notes.js'
65
64
  import {
66
65
  extendWorkspaceFile,
@@ -642,14 +641,6 @@ function lockRow(lock: { code: number; said: string }): ui.FieldRow {
642
641
  /** `.vx/.gitignore`'s text, as core writes it: the machine-local cache and restore staging stay out of git. */
643
642
  const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
644
643
 
645
- // npx and bunx name the package: their bare `vx` is a stranger's npm package.
646
- const EXEC: Record<PackageManager, string> = {
647
- pnpm: 'pnpm vx',
648
- yarn: 'yarn vx',
649
- bun: 'bunx @vzn/vx',
650
- npm: 'npx @vzn/vx',
651
- }
652
-
653
644
  /**
654
645
  * Install what the workspace file imports, if missing, then run the repo's
655
646
  * build. Pinned to this release: every @vzn package releases in lockstep,
@@ -666,12 +657,12 @@ export function adoptionNext(
666
657
  const missing = needs.filter(
667
658
  (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
668
659
  )
669
- // A global vx (no runner started this one) runs the workspace's plugins as they are.
670
- const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : EXEC[pm]
671
- const run = `${vx} run ${firstTask(path.join(root, source), runner)} --all`
660
+ // `vx init` installs vx globally first: vx alone, never a package
661
+ // manager's runner (owner, 2026-10-22: vx owns the command).
662
+ const run = `vx run ${firstTask(path.join(root, source), runner)} --all`
672
663
  if (missing.length === 0) return run
673
664
  const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
674
- return `${installArgv(root, pm, pinned).join(' ')} && ${vx} lock && ${run}`
665
+ return `${installArgv(root, pm, pinned).join(' ')} && vx lock && ${run}`
675
666
  }
676
667
 
677
668
  /**
package/src/nx/nx-map.ts CHANGED
@@ -284,11 +284,20 @@ export interface NxMapping {
284
284
  readonly notes: string[]
285
285
  }
286
286
 
287
- /** A node's tags as vx takes them: a blank one would be refused at load. */
287
+ /** A command that still runs Nx or Lerna (`nx-exec`, `nx-env`, `nx exec`, `lerna run`) or reads what they set. */
288
+ const RUNS_NX = /(?:^|[\s;&|('"/])(?:nx(?:-exec|-env)?|lerna)\s|NX_TASK_TARGET_|LERNA_PACKAGE_NAME/
289
+
290
+ /**
291
+ * A node's tags as vx takes them: a blank one would be refused at load, and
292
+ * `npm:public` / `npm:private` are Nx's own, read off package.json.
293
+ */
288
294
  function nxTags(node: NxNode | undefined): string[] {
289
295
  const tags = node?.data?.tags
290
296
  return Array.isArray(tags)
291
- ? tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
297
+ ? tags.filter(
298
+ (t): t is string =>
299
+ typeof t === 'string' && t.trim() !== '' && t !== 'npm:public' && t !== 'npm:private',
300
+ )
292
301
  : []
293
302
  }
294
303
 
@@ -481,7 +490,7 @@ export async function mapNxWorkspace(
481
490
  }
482
491
  mapped.push({ meta, tasks })
483
492
  }
484
- // The `nx-input:<name>` twins the `^` inputs above asked for, in every
493
+ // The `inputs:<name>` twins the `^` inputs above asked for, in every
485
494
  // project a graph node owns — one with no targets too.
486
495
  const byMeta = new Map(mapped.map((m) => [m.meta, m]))
487
496
  for (const [nodeName, list] of upstream.inputTasks()) {
@@ -898,7 +907,7 @@ function buildTask(
898
907
  // saw nothing the task reads, and a changed document replayed the old
899
908
  // generated client (Growth, 2026-10-16).
900
909
  const orphanReader =
901
- inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#nx-input:'))
910
+ inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#inputs:'))
902
911
  if (orphanReader && cacheWanted && !persistent) {
903
912
  todos.push(orphanReaderTodo(inputs.dependentOutputs, target.dependsOn))
904
913
  }
@@ -967,18 +976,24 @@ function buildTask(
967
976
  // `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
968
977
  // unset, it booted Nx's own task runner, which ran the target and its
969
978
  // dependencies again. `LERNA_PACKAGE_NAME` is the project too: Lerna
970
- // runs on Nx's runner and documents it to scripts. A run-commands `env`
971
- // still wins, as in Nx.
972
- env.define = {
973
- NX_TASK_TARGET_PROJECT: projectName,
974
- NX_TASK_TARGET_TARGET: targetName,
975
- LERNA_PACKAGE_NAME: projectName,
976
- ...(variant.configuration === undefined
977
- ? {}
978
- : { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
979
+ // runs on Nx's runner and documents it to scripts. Only a command that
980
+ // still runs Nx or Lerna gets them: a native task names no other runner
981
+ // (owner, 2026-10-22). A run-commands `env` still wins, as in Nx.
982
+ const define = {
983
+ ...(RUNS_NX.test(mapped.command)
984
+ ? {
985
+ NX_TASK_TARGET_PROJECT: projectName,
986
+ NX_TASK_TARGET_TARGET: targetName,
987
+ LERNA_PACKAGE_NAME: projectName,
988
+ ...(variant.configuration === undefined
989
+ ? {}
990
+ : { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
991
+ }
992
+ : {}),
979
993
  ...mapped.env,
980
994
  }
981
- exec.env = env
995
+ if (Object.keys(define).length > 0) env.define = define
996
+ if (Object.keys(env).length > 0) exec.env = env
982
997
  if (mapped.timeout !== undefined) exec.timeout = mapped.timeout
983
998
  if (readyWhen !== undefined) {
984
999
  exec.persistent = { readyWhen }
@@ -1040,7 +1055,7 @@ function reachesProducer(
1040
1055
  if (!Array.isArray(deps)) return false
1041
1056
  return deps.some((d) => {
1042
1057
  if (typeof d !== 'string' || d.includes('*')) return true
1043
- if (d.includes('#nx-input:')) return false
1058
+ if (d.includes('#inputs:')) return false
1044
1059
  if (d.startsWith('^'))
1045
1060
  return [...reached(project)].some((p) => emittedIds.has(`${p}#${d.slice(1)}`))
1046
1061
  return d.includes('#') || emittedIds.has(`${project}#${d}`)
@@ -2,7 +2,7 @@
2
2
  // dependency's `name` input for every project the graph reaches, whether or
3
3
  // not a task edge exists, so a `test` with `^production` and no `dependsOn`
4
4
  // re-keys on a dependency's source. vx folds upstream KEYS along
5
- // `dependsOn`, so each project gets one `nx-input:<name>` task — a keyed
5
+ // `dependsOn`, so each project gets one `inputs:<name>` task — a keyed
6
6
  // group, keyed on the project's own `name` input — with an edge to its
7
7
  // dependencies' twin. A task that reads `^name` depends on its direct
8
8
  // dependencies' twins and folds the whole closure through them: each
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // The edges are the NX graph's, not vx's package graph (`^` in vx follows
13
13
  // package.json, and an Nx edge from a tsconfig path has no manifest entry),
14
- // so they are explicit `pkg#nx-input:<name>`. A graph node with no vx
14
+ // so they are explicit `pkg#inputs:<name>`. A graph node with no vx
15
15
  // project is transparent: its files join as workspace globs and the walk
16
16
  // goes on through it. Nx allows a project cycle and vx's task graph does
17
17
  // not: inside a cycle a twin carries its peers' files as workspace globs
@@ -47,8 +47,8 @@ const isFileset = (name: string): boolean =>
47
47
  /** A fileset twin is named by its hash: a glob's `*` is no task name. */
48
48
  const inputTask = (name: string): string =>
49
49
  isFileset(name)
50
- ? `nx-input:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
51
- : `nx-input:${name}`
50
+ ? `inputs:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
51
+ : `inputs:${name}`
52
52
 
53
53
  function normRel(p: string): string {
54
54
  const s = p.replace(/\/+$/, '')
@@ -63,7 +63,7 @@ export interface NxUpstream {
63
63
  * needs, with a transparent node's files added to `into`.
64
64
  */
65
65
  resolve(node: string, into: NxInputs, todos: string[]): string[]
66
- /** Each project's `nx-input:<name>` tasks, for every twin an edge reaches. */
66
+ /** Each project's `inputs:<name>` tasks, for every twin an edge reaches. */
67
67
  inputTasks(): Map<string, GeneratedTask[]>
68
68
  }
69
69
 
@@ -192,18 +192,24 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
192
192
  }
193
193
 
194
194
  /**
195
- * Can the keeper's `ga` give `gb`'s tree back? Only when `ga` is a
196
- * directory at any depth (`**\/dist`, `**\/dist/**`) and `gb`'s literal
197
- * head names no such directory: the two meet only at a `dist` nested in
198
- * `gb`'s tree, which is the sibling's. solid's build outputs `**\/dist`
199
- * beside types' `types/**`; Turbo caches both, and uncaching types made
200
- * vx the slower of the two.
195
+ * Can the keeper's `ga` give `gb`'s tree back? Only when one of the two is
196
+ * a directory at any depth (`**\/dist`, `**\/dist/**`) and the other's
197
+ * literal head names no such directory: they meet only where one tree
198
+ * nests in the other, which neither task writes. solid's build outputs
199
+ * `dist/**` and `**\/dist` beside types' `types/**` and `**\/types`; Turbo
200
+ * caches both, and uncaching types made vx the slower of the two.
201
201
  */
202
- function separable(ga: string, gb: string): boolean {
203
- const name = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/.exec(ga)?.[1]
202
+ const DIR_ANYWHERE = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/
203
+
204
+ const separable = (ga: string, gb: string): boolean => apart(ga, gb) || apart(gb, ga)
205
+
206
+ function apart(dir: string, other: string): boolean {
207
+ const name = DIR_ANYWHERE.exec(dir)?.[1]
204
208
  if (name === undefined) return false
209
+ const otherName = DIR_ANYWHERE.exec(other)?.[1]
210
+ if (otherName !== undefined) return otherName !== name
205
211
  const head: string[] = []
206
- for (const seg of gb.replace(/^(\.\/)+/, '').split('/')) {
212
+ for (const seg of other.replace(/^(\.\/)+/, '').split('/')) {
207
213
  if (!isLiteralPattern(seg) || seg === '') break
208
214
  head.push(seg)
209
215
  }
@@ -348,7 +354,7 @@ function prefixIndex(): {
348
354
  }
349
355
 
350
356
  /**
351
- * A keyed group (`cache`, no `exec`: the `nx-input:<name>` twins, Turbo's
357
+ * A keyed group (`cache`, no `exec`: the `inputs:<name>` twins, Turbo's
352
358
  * transit nodes) runs nothing, so core lets its key read what a task
353
359
  * writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
354
360
  * task core refuses, and every verb failed.
@@ -11,7 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
13
13
  import { relPosix } from './paths.js'
14
- import { isKeyedGroup, unkey } from './shared-outputs.js'
14
+ import type * as SharedOutputs from './shared-outputs.js'
15
15
 
16
16
  /** The files git tracks under `root`, root-relative; null outside a repo or without git. */
17
17
  export async function trackedFiles(root: string): Promise<string[] | null> {
@@ -135,11 +135,13 @@ export async function headStamp(root: string): Promise<string> {
135
135
  const m = /^gitdir: (.*)$/m.exec(await readFile(dotGit, 'utf8').catch(() => ''))
136
136
  if (m !== null) gitDir = path.resolve(dir, m[1]!.trim())
137
137
  }
138
- const head = await readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => '')
139
- const log = await lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null)
140
- const index = await lstat(path.join(gitDir, 'index')).catch(() => null)
141
- // A worktree's own stack holds its HEAD; the common one, its branches.
142
- const common = await readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null)
138
+ const [head, log, index, common] = await Promise.all([
139
+ readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => ''),
140
+ lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null),
141
+ lstat(path.join(gitDir, 'index')).catch(() => null),
142
+ // A worktree's own stack holds its HEAD; the common one, its branches.
143
+ readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null),
144
+ ])
143
145
  const tables = await Promise.all(
144
146
  [gitDir, ...(common === null ? [] : [path.resolve(gitDir, common.trim())])].map((d) =>
145
147
  readFile(path.join(d, 'reftable', 'tables.list'), 'utf8').catch(() => ''),
@@ -236,6 +238,8 @@ export function spareTrackedOutputs(
236
238
  }[],
237
239
  tracked: readonly string[],
238
240
  ): [string, string][] {
241
+ // Loaded here, on a remap only: a kept mapping's run never reads it.
242
+ const { isKeyedGroup, unkey } = require('./shared-outputs.js') as typeof SharedOutputs
239
243
  const todos: [string, string][] = []
240
244
  const trackedSet = new Set(tracked)
241
245
  /** Drop the `!` entries readers carry for a committed file no output names any more. */
@@ -21,14 +21,14 @@ import { collectGaps } from '../plugin-gaps.js'
21
21
  import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
22
22
  import { packageScripts, relPosix } from '../paths.js'
23
23
  import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
24
+ import type * as TurboMap from './turbo-map.js'
25
+ import type { TurboMappedProject } from './turbo-map.js'
24
26
  import {
25
27
  envNamesThatMap,
26
- mapTurboWorkspace,
27
28
  microfrontendsConfigs,
28
29
  rootTaskProject,
29
30
  turboConfigFile,
30
- type TurboMappedProject,
31
- } from './turbo-map.js'
31
+ } from './turbo-reads.js'
32
32
 
33
33
  /** The note every persistent task carries; like every gap, reported once per run for all its tasks. */
34
34
  const PERSISTENT_NOTE =
@@ -192,11 +192,15 @@ const textOf = (file: string): Promise<string> =>
192
192
  * `.yarnrc.yml` and the microfrontends configs.
193
193
  */
194
194
  async function run(root: string, projects: readonly ProjectMeta[]): Promise<AdoptionRun> {
195
- const metas = await withManifests(projects)
196
- const dirs = [root, ...metas.map((m) => m.dir)]
197
- const configs = await Promise.all(
198
- dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
199
- )
195
+ const dirs = [root, ...projects.map((m) => m.dir)]
196
+ const [metas, configs, yarnrc, rootManifest] = await Promise.all([
197
+ withManifests(projects),
198
+ Promise.all(
199
+ dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
200
+ ),
201
+ textOf(path.join(root, '.yarnrc.yml')),
202
+ textOf(path.join(root, 'package.json')),
203
+ ])
200
204
  const envNames = envNamesThatMap(configs, envNamesNow())
201
205
  // The tsconfigs a `tsc` script reads are the mapping's inputs too.
202
206
  const tsc = onceReader()
@@ -213,9 +217,9 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
213
217
  process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
214
218
  process.env['TURBO_ENV_MODE'] ?? '',
215
219
  ...configs,
216
- await textOf(path.join(root, '.yarnrc.yml')),
220
+ yarnrc,
217
221
  // Its dependencies are global inputs, a member or not.
218
- await textOf(path.join(root, 'package.json')),
222
+ rootManifest,
219
223
  JSON.stringify(microfrontendsConfigs(root, dirs)),
220
224
  // A config file added beside mapped tasks changes what an output may cover.
221
225
  JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
@@ -244,6 +248,8 @@ async function mapAll(
244
248
  m.configPath === null ? [] : [[relPosix(root, m.dir), path.basename(m.configPath)] as const],
245
249
  ),
246
250
  )
251
+ // Only a mapping the kept one no longer holds loads the mapper.
252
+ const { mapTurboWorkspace } = require('./turbo-map.js') as typeof TurboMap
247
253
  const mapped = await mapTurboWorkspace(root, metas, {
248
254
  // Inline: the values themselves, where `bunx @vzn/vx-migrate` splices a preset import.
249
255
  splice: (_kind, values) => values,
@@ -10,7 +10,6 @@
10
10
  // written.
11
11
  // The consumer decides what a global becomes through `splice`.
12
12
 
13
- import { existsSync, readFileSync } from 'node:fs'
14
13
  import path from 'node:path'
15
14
  import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
16
15
  import type { PackageMeta } from '../packages.js'
@@ -31,6 +30,13 @@ import {
31
30
  } from '../shared-outputs.js'
32
31
  import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
33
32
  import { isRecord, packageScripts, relPosix } from '../paths.js'
33
+ import {
34
+ FRAMEWORK_ENV,
35
+ keyable,
36
+ microfrontendsConfigs,
37
+ ROOT,
38
+ turboConfigFile,
39
+ } from './turbo-reads.js'
34
40
  import type { TrackedKinds } from '../tracked-outputs.js'
35
41
  import {
36
42
  DOTENV_PROBE,
@@ -69,34 +75,6 @@ interface TurboJson {
69
75
 
70
76
  const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
71
77
 
72
- /** A name core takes in `inputs.env` and `exec.env.passThrough`. */
73
- const keyable = (name: string): boolean =>
74
- name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
75
-
76
- /**
77
- * The names of `names` a live mapping can read differently: each a `*` in
78
- * one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
79
- * framework table matches, and each core cannot key (a literal one is
80
- * dropped only when no variable has it). The kept mapping keyed on every
81
- * name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
82
- * remapped the workspace: ~330 ms a run on vercel/ai.
83
- */
84
- export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
85
- const patterns = new Set(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
86
- for (const text of configs)
87
- for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
88
- if (m[1]!.includes('*')) patterns.add(m[1]!)
89
- // Backslashes and a leading `!` dropped, every `*` a wildcard: wider
90
- // than Turbo's reading, never narrower.
91
- const res = [...patterns].map(
92
- (p) =>
93
- new RegExp(
94
- `^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
95
- ),
96
- )
97
- return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
98
- }
99
-
100
78
  /**
101
79
  * A Turbo env list's explicit names, read as Turbo reads them
102
80
  * (`wildcard_to_regex_pattern`): `*` is the one wildcard, `\*` a
@@ -195,83 +173,6 @@ const KNOWN_TASK_KEYS = new Set([
195
173
  const OUTPUT_LOGS_DEFAULT = 'new-only'
196
174
  const OUTPUT_LOGS_RUN_FLAG = new Set(['full', 'hash-only', 'errors-only', 'none'])
197
175
 
198
- // Turbo 2's framework inference: a package that depends on a framework
199
- // has its variables hashed into its tasks and passed to them, with
200
- // nothing in turbo.json saying so. vx env names are explicit, so the
201
- // variables were stripped in silence and a build that inlines them
202
- // (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
203
- // (`envNames`) infers them as Turbo does; the migrate CLI lists the names
204
- // the package's own files spell (`sourceNames`) and says so in a note.
205
- // Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
206
- // in its order: a package takes the FIRST framework it matches, `all`
207
- // needing every dependency and `some` any one.
208
- const NITRO_ENV = [
209
- 'NITRO_*',
210
- 'SERVER_*',
211
- 'AWS_APP_ID',
212
- 'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
213
- 'CLEAVR',
214
- 'CF_PAGES',
215
- 'FIREBASE_APP_HOSTING',
216
- 'NETLIFY',
217
- 'STORMKIT',
218
- 'NOW_BUILDER',
219
- 'ZEABUR',
220
- 'RENDER',
221
- ]
222
- const FRAMEWORK_ENV: ReadonlyArray<{
223
- slug: string
224
- env: readonly string[]
225
- all: boolean
226
- dependencies: readonly string[]
227
- }> = [
228
- { slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
229
- { slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
230
- {
231
- slug: 'create-react-app',
232
- env: ['REACT_APP_*'],
233
- all: false,
234
- dependencies: ['react-scripts', 'react-dev-utils'],
235
- },
236
- { slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
237
- { slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
238
- {
239
- slug: 'nextjs',
240
- env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
241
- all: true,
242
- dependencies: ['next'],
243
- },
244
- {
245
- slug: 'nitro',
246
- env: NITRO_ENV,
247
- all: false,
248
- dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
249
- },
250
- {
251
- slug: 'nuxtjs',
252
- env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
253
- all: false,
254
- dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
255
- },
256
- { slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
257
- {
258
- slug: 'remix',
259
- env: ['REMIX_*'],
260
- all: false,
261
- dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
262
- },
263
- { slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
264
- { slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
265
- { slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
266
- { slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
267
- {
268
- slug: 'vue',
269
- env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
270
- all: true,
271
- dependencies: ['@vue/cli-service'],
272
- },
273
- ]
274
-
275
176
  /** The three global fields of turbo.json a task may draw on. */
276
177
  export type TurboGlobal = 'inputs' | 'env' | 'pass'
277
178
 
@@ -424,45 +325,6 @@ function keyIgnored(
424
325
  inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
425
326
  }
426
327
 
427
- /**
428
- * A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
429
- * reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
430
- * failed the run with ENOENT and a package's `turbo.jsonc` overlay was
431
- * skipped in silence — its `inputs` then keyed nothing (item 938).
432
- */
433
- export async function turboConfigFile(dir: string): Promise<string | null> {
434
- for (const name of ['turbo.json', 'turbo.jsonc']) {
435
- const file = path.join(dir, name)
436
- if (await Bun.file(file).exists()) return file
437
- }
438
- return null
439
- }
440
-
441
- /**
442
- * The workspace root as a project to name when turbo.json declares `//#`
443
- * tasks: its package name and directory. Null with no such task, a
444
- * nameless root, or a file the mapping refuses (it names the file).
445
- */
446
- export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
447
- const file = await turboConfigFile(root)
448
- if (file === null) return null
449
- let turbo: { tasks?: unknown; pipeline?: unknown } | null
450
- let pkg: { name?: unknown } | null
451
- try {
452
- turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
453
- pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
454
- } catch {
455
- return null
456
- }
457
- const tasks = turbo?.tasks ?? turbo?.pipeline
458
- const hasRootTask =
459
- typeof tasks === 'object' &&
460
- tasks !== null &&
461
- Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
462
- if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
463
- return { name: pkg.name, dir: root }
464
- }
465
-
466
328
  async function readTurboJson(file: string, root: string): Promise<TurboJson> {
467
329
  const text = await Bun.file(file).text()
468
330
  let parsed: unknown
@@ -606,7 +468,6 @@ function badCommand(def: TurboTask): boolean {
606
468
 
607
469
  /** Declared task names for a package: plain root keys, `pkg#name` keys
608
470
  * for this package, and per-package turbo.json keys — in that order. */
609
- const ROOT = '//'
610
471
 
611
472
  /**
612
473
  * A package's turbo.json files, root first and its own last, as Turbo's
@@ -892,36 +753,6 @@ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']
892
753
  return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
893
754
  }
894
755
 
895
- /**
896
- * The microfrontends configs Turbo hashes into every task: each package's
897
- * (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
898
- * the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
899
- * child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
900
- * the root's global deps). Unread, an edit to `web`'s routes replayed every
901
- * sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
902
- * 18 tasks). Paths are root-relative; `text` is what the mapping read.
903
- */
904
- export function microfrontendsConfigs(
905
- root: string,
906
- dirs: readonly string[],
907
- custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
908
- ): { rel: string; text: string }[] {
909
- const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
910
- const out: { rel: string; text: string }[] = []
911
- for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
912
- const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
913
- if (file === undefined) continue
914
- const text = readFileSync(file, 'utf8')
915
- let child = false
916
- try {
917
- const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
918
- child = typeof parsed?.partOf === 'string'
919
- } catch {}
920
- if (!child) out.push({ rel: relPosix(root, file), text })
921
- }
922
- return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
923
- }
924
-
925
756
  export async function mapTurboWorkspace(
926
757
  root: string,
927
758
  metas: readonly PackageMeta[],
@@ -1177,7 +1008,7 @@ export async function mapTurboWorkspace(
1177
1008
  // over its files, so `test` keys on its dependencies' sources. Dropped,
1178
1009
  // vx keyed `test` on its own files alone: a dependency's edit was a hit.
1179
1010
  // Each package has it as a key-only task, a keyed group, as nx()'s
1180
- // `nx-input:*` twins are.
1011
+ // `inputs:*` twins are.
1181
1012
  const withScript = new Set<string>()
1182
1013
  for (const set of runnable.values()) for (const name of set) withScript.add(name)
1183
1014
  const sameRefs = new Set<string>()
@@ -0,0 +1,183 @@
1
+ // What a turbo() run reads before it knows whether its kept mapping still
2
+ // holds: the mapping itself (`turbo-map.ts`, ~240 KB with what it imports)
3
+ // loads only when it does not.
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import path from 'node:path'
6
+ import { relPosix } from '../paths.js'
7
+
8
+ /** Turbo's name for the workspace root in `//#task` and `extends`. */
9
+ export const ROOT = '//'
10
+
11
+ // Turbo 2's framework inference: a package that depends on a framework
12
+ // has its variables hashed into its tasks and passed to them, with
13
+ // nothing in turbo.json saying so. vx env names are explicit, so the
14
+ // variables were stripped in silence and a build that inlines them
15
+ // (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
16
+ // (`envNames`) infers them as Turbo does; the migrate CLI lists the names
17
+ // the package's own files spell (`sourceNames`) and says so in a note.
18
+ // Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
19
+ // in its order: a package takes the FIRST framework it matches, `all`
20
+ // needing every dependency and `some` any one.
21
+ const NITRO_ENV = [
22
+ 'NITRO_*',
23
+ 'SERVER_*',
24
+ 'AWS_APP_ID',
25
+ 'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
26
+ 'CLEAVR',
27
+ 'CF_PAGES',
28
+ 'FIREBASE_APP_HOSTING',
29
+ 'NETLIFY',
30
+ 'STORMKIT',
31
+ 'NOW_BUILDER',
32
+ 'ZEABUR',
33
+ 'RENDER',
34
+ ]
35
+ export const FRAMEWORK_ENV: ReadonlyArray<{
36
+ slug: string
37
+ env: readonly string[]
38
+ all: boolean
39
+ dependencies: readonly string[]
40
+ }> = [
41
+ { slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
42
+ { slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
43
+ {
44
+ slug: 'create-react-app',
45
+ env: ['REACT_APP_*'],
46
+ all: false,
47
+ dependencies: ['react-scripts', 'react-dev-utils'],
48
+ },
49
+ { slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
50
+ { slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
51
+ {
52
+ slug: 'nextjs',
53
+ env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
54
+ all: true,
55
+ dependencies: ['next'],
56
+ },
57
+ {
58
+ slug: 'nitro',
59
+ env: NITRO_ENV,
60
+ all: false,
61
+ dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
62
+ },
63
+ {
64
+ slug: 'nuxtjs',
65
+ env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
66
+ all: false,
67
+ dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
68
+ },
69
+ { slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
70
+ {
71
+ slug: 'remix',
72
+ env: ['REMIX_*'],
73
+ all: false,
74
+ dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
75
+ },
76
+ { slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
77
+ { slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
78
+ { slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
79
+ { slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
80
+ {
81
+ slug: 'vue',
82
+ env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
83
+ all: true,
84
+ dependencies: ['@vue/cli-service'],
85
+ },
86
+ ]
87
+
88
+ /** A name core takes in `inputs.env` and `exec.env.passThrough`. */
89
+ export const keyable = (name: string): boolean =>
90
+ name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
91
+
92
+ /**
93
+ * The names of `names` a live mapping can read differently: each a `*` in
94
+ * one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
95
+ * framework table matches, and each core cannot key (a literal one is
96
+ * dropped only when no variable has it). The kept mapping keyed on every
97
+ * name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
98
+ * remapped the workspace: ~330 ms a run on vercel/ai.
99
+ */
100
+ export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
101
+ const patterns = new Set(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
102
+ for (const text of configs)
103
+ for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
104
+ if (m[1]!.includes('*')) patterns.add(m[1]!)
105
+ // Backslashes and a leading `!` dropped, every `*` a wildcard: wider
106
+ // than Turbo's reading, never narrower.
107
+ const res = [...patterns].map(
108
+ (p) =>
109
+ new RegExp(
110
+ `^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
111
+ ),
112
+ )
113
+ return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
114
+ }
115
+
116
+ /**
117
+ * A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
118
+ * reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
119
+ * failed the run with ENOENT and a package's `turbo.jsonc` overlay was
120
+ * skipped in silence — its `inputs` then keyed nothing (item 938).
121
+ */
122
+ export async function turboConfigFile(dir: string): Promise<string | null> {
123
+ for (const name of ['turbo.json', 'turbo.jsonc']) {
124
+ const file = path.join(dir, name)
125
+ if (await Bun.file(file).exists()) return file
126
+ }
127
+ return null
128
+ }
129
+
130
+ /**
131
+ * The workspace root as a project to name when turbo.json declares `//#`
132
+ * tasks: its package name and directory. Null with no such task, a
133
+ * nameless root, or a file the mapping refuses (it names the file).
134
+ */
135
+ export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
136
+ const file = await turboConfigFile(root)
137
+ if (file === null) return null
138
+ let turbo: { tasks?: unknown; pipeline?: unknown } | null
139
+ let pkg: { name?: unknown } | null
140
+ try {
141
+ turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
142
+ pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
143
+ } catch {
144
+ return null
145
+ }
146
+ const tasks = turbo?.tasks ?? turbo?.pipeline
147
+ const hasRootTask =
148
+ typeof tasks === 'object' &&
149
+ tasks !== null &&
150
+ Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
151
+ if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
152
+ return { name: pkg.name, dir: root }
153
+ }
154
+
155
+ /**
156
+ * The microfrontends configs Turbo hashes into every task: each package's
157
+ * (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
158
+ * the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
159
+ * child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
160
+ * the root's global deps). Unread, an edit to `web`'s routes replayed every
161
+ * sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
162
+ * 18 tasks). Paths are root-relative; `text` is what the mapping read.
163
+ */
164
+ export function microfrontendsConfigs(
165
+ root: string,
166
+ dirs: readonly string[],
167
+ custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
168
+ ): { rel: string; text: string }[] {
169
+ const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
170
+ const out: { rel: string; text: string }[] = []
171
+ for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
172
+ const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
173
+ if (file === undefined) continue
174
+ const text = readFileSync(file, 'utf8')
175
+ let child = false
176
+ try {
177
+ const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
178
+ child = typeof parsed?.partOf === 'string'
179
+ } catch {}
180
+ if (!child) out.push({ rel: relPosix(root, file), text })
181
+ }
182
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
183
+ }
@@ -18,6 +18,7 @@ import path from 'node:path'
18
18
  import {
19
19
  type ArtifactInfo,
20
20
  definePlugin,
21
+ type LentAccess,
21
22
  type RemoteCacheLayer,
22
23
  type RemoteOpenContext,
23
24
  type VxPlugin,
@@ -428,6 +429,9 @@ async function boundedText(res: Response, max: number): Promise<string | null> {
428
429
  */
429
430
  const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
430
431
 
432
+ /** A lent token ending sooner than this is exchanged again first, so an agent's request never carries a spent one. */
433
+ const LEND_MARGIN_MS = 5 * 60_000
434
+
431
435
  /**
432
436
  * The seam implementation: `has` is HEAD, `hasMany` is the batch query,
433
437
  * `get`/`put` carry `x-artifact-duration` (and the tag when signing). An
@@ -464,8 +468,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
464
468
  private token: string | undefined
465
469
  /** Set by `open` when the token came from the keychain: a refusal deletes it. */
466
470
  private forget: (() => Promise<void>) | undefined
467
- /** The token an OIDC exchange gave, revoked when the run ends. */
468
- private exchanged: string | undefined
471
+ /** The tokens OIDC exchanges gave, the last one current, each revoked when the run ends. */
472
+ private exchanged: string[] = []
473
+ /** When the current exchanged token ends (epoch ms), when the server said. */
474
+ private expiresAt: number | undefined
475
+ /** Set by `open` when this machine has a lent token and no token of its own: read per request. */
476
+ private borrowed: (() => string | undefined) | undefined
477
+ /** The token a run running its tasks here lent (`CacheContext.lent`). */
478
+ lent: (() => string | undefined) | undefined
469
479
  private readonly keychainRemote: KeychainRemote
470
480
  /** The remote's rate limit, which every request of this client waits out. */
471
481
  private readonly cooldown = new Cooldown()
@@ -491,26 +501,59 @@ export class TurboRemoteCache implements RemoteCacheLayer {
491
501
  }
492
502
 
493
503
  async open(ctx: RemoteOpenContext): Promise<void> {
494
- if (this.config.oidcMissing)
504
+ const lent = this.lent?.() === undefined ? undefined : this.lent
505
+ if (this.config.oidcMissing && lent === undefined)
495
506
  ctx.warn(
496
507
  'oidc: GitHub gave this job no OIDC token: it needs `permissions: id-token: write` (a fork or Dependabot pull request never gets one)',
497
508
  )
498
509
  if (this.token !== undefined) return
510
+ // A lent token beats this machine's own OIDC: the server files a write
511
+ // by the token, so an agent's own (another workflow, another ref)
512
+ // filed the task where its run could not read it.
513
+ if (lent !== undefined) {
514
+ this.borrowed = lent
515
+ return
516
+ }
499
517
  if (this.config.oidc !== undefined) {
500
518
  try {
501
- this.token = await exchangeOidc(this.config.oidc, this.fetchImpl, this.wait)
519
+ await this.exchange()
502
520
  } catch (err) {
503
521
  throw new Error(
504
522
  `oidc: ${err instanceof Error ? err.message : String(err)}; local cache only this run`,
505
523
  )
506
524
  }
507
- this.exchanged = this.token
508
525
  return
509
526
  }
510
527
  this.token = await keychainToken(ctx, this.keychainRemote)
511
528
  this.forget = () => ctx.forgetSecret({ key: this.keychainRemote.key })
512
529
  }
513
530
 
531
+ private async exchange(): Promise<void> {
532
+ const got = await exchangeOidc(this.config.oidc!, this.fetchImpl, this.wait)
533
+ this.token = got.token
534
+ this.expiresAt = got.expiresAt
535
+ this.exchanged.push(got.token)
536
+ }
537
+
538
+ /**
539
+ * The token an OIDC exchange gave this run, for a machine that runs its
540
+ * tasks (`RemoteCacheLayer.lend`); exchanged again when it ends within
541
+ * five minutes. A token of any other kind is never lent.
542
+ */
543
+ async lend(): Promise<LentAccess | undefined> {
544
+ if (this.exchanged.length === 0) return undefined
545
+ if (this.expiresAt !== undefined && this.expiresAt - Date.now() < LEND_MARGIN_MS)
546
+ await this.exchange()
547
+ return {
548
+ token: this.token!,
549
+ ...(this.expiresAt === undefined ? {} : { expiresAt: this.expiresAt }),
550
+ }
551
+ }
552
+
553
+ private bearer(): string | undefined {
554
+ return this.borrowed?.() ?? this.token
555
+ }
556
+
514
557
  private url(pathname: string): string {
515
558
  const u = new URL(`${this.config.apiUrl}/v8/artifacts${pathname}`)
516
559
  // Turbo sends a team id only in Vercel's `team_` form (its API client's
@@ -523,7 +566,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
523
566
 
524
567
  private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
525
568
  return {
526
- ...(auth ? { Authorization: `Bearer ${this.token}` } : {}),
569
+ ...(auth ? { Authorization: `Bearer ${this.bearer()}` } : {}),
527
570
  'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
528
571
  ...extra,
529
572
  }
@@ -550,7 +593,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
550
593
  const res = await this.fetchImpl(url, {
551
594
  method: 'OPTIONS',
552
595
  headers: {
553
- Authorization: `Bearer ${this.token}`,
596
+ Authorization: `Bearer ${this.bearer()}`,
554
597
  'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
555
598
  'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
556
599
  },
@@ -579,7 +622,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
579
622
  pathname: string,
580
623
  init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
581
624
  ): Promise<Response | undefined> {
582
- if (this.token === undefined) return undefined
625
+ if (this.bearer() === undefined) return undefined
583
626
  const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
584
627
  // 0 is Turbo's "no deadline".
585
628
  const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
@@ -629,12 +672,11 @@ export class TurboRemoteCache implements RemoteCacheLayer {
629
672
  return res
630
673
  }
631
674
 
632
- /** Revoke the token `open` exchanged, once; a throw is the caller's warning. */
675
+ /** Revoke each token this run exchanged, once; a throw is the caller's warning. */
633
676
  async revoke(): Promise<void> {
634
- const token = this.exchanged
635
- if (token === undefined) return
636
- this.exchanged = undefined
637
- await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
677
+ const tokens = this.exchanged.splice(0)
678
+ for (const token of tokens)
679
+ await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
638
680
  }
639
681
 
640
682
  async has(hash: string): Promise<ArtifactInfo | null> {
@@ -859,6 +901,7 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
859
901
  // sandboxed task may read (L-45).
860
902
  const layer = new TurboRemoteCache(config, fetch, ctx.cacheDir)
861
903
  layer.reads = readScopes(ctx.refs)
904
+ layer.lent = ctx.lent
862
905
  if (config.oidc !== undefined) opened = { layer, warn: ctx.warn }
863
906
  return scope === 'key' ? keyedByRef(layer, ctx.refs) : layer
864
907
  },
@@ -111,12 +111,18 @@ async function field(res: Response, name: string, what: string): Promise<string>
111
111
  return value
112
112
  }
113
113
 
114
+ /** An exchanged token, and when it ends (epoch ms) when the server says. */
115
+ export interface Exchanged {
116
+ readonly token: string
117
+ readonly expiresAt?: number
118
+ }
119
+
114
120
  /** The job's OIDC token, exchanged for the cache's access token. Errors never quote a token. */
115
121
  export async function exchangeOidc(
116
122
  cfg: OidcConfig,
117
123
  fetchImpl: typeof fetch = fetch,
118
124
  wait: (ms: number) => Promise<void> = Bun.sleep,
119
- ): Promise<string> {
125
+ ): Promise<Exchanged> {
120
126
  const idUrl = new URL(cfg.idTokenUrl)
121
127
  if (cfg.audience !== undefined) idUrl.searchParams.set('audience', cfg.audience)
122
128
  const idToken = await field(
@@ -129,22 +135,31 @@ export async function exchangeOidc(
129
135
  'value',
130
136
  `GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
131
137
  )
132
- return field(
133
- await send(
134
- fetchImpl,
135
- cfg.tokenUrl,
136
- form({
137
- grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
138
- subject_token: idToken,
139
- subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
140
- requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
141
- ...cfg.params,
142
- }),
143
- wait,
144
- ),
145
- 'access_token',
146
- `the token exchange (${where(cfg.tokenUrl)})`,
138
+ const res = await send(
139
+ fetchImpl,
140
+ cfg.tokenUrl,
141
+ form({
142
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
143
+ subject_token: idToken,
144
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
145
+ requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
146
+ ...cfg.params,
147
+ }),
148
+ wait,
147
149
  )
150
+ const what = `the token exchange (${where(cfg.tokenUrl)})`
151
+ if (!res.ok) {
152
+ await res.body?.cancel()
153
+ throw new Error(`${what} answered HTTP ${res.status}`)
154
+ }
155
+ const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
156
+ const token = body?.['access_token']
157
+ if (typeof token !== 'string' || token === '') throw new Error(`${what} sent no access_token`)
158
+ const ttl = body?.['expires_in']
159
+ return {
160
+ token,
161
+ ...(typeof ttl === 'number' && ttl > 0 ? { expiresAt: Date.now() + ttl * 1000 } : {}),
162
+ }
148
163
  }
149
164
 
150
165
  /** Revoke the exchanged token; a throw is the caller's warning. */