@vzn/vx-migrate 0.0.650 → 0.0.652

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
@@ -30,7 +30,7 @@ The package exports `turbo`, `nx`, `turboCache`, `nxCache` and their options typ
30
30
 
31
31
  ## `turbo()` — a Turbo repo, mid-migration
32
32
 
33
- Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every package's `build` script the way `turbo run build` would: `dependsOn` edges (`^build`, same-package deps, `pkg#task`), `inputs` as every task's key and `outputs` as the cache block, `env` / `passThroughEnv`, `cache: false`, `persistent`. Turbo's global fields (`globalDependencies`, `globalEnv`, `globalPassThroughEnv`, Turbo 1's `globalDotEnv`) are inlined into every task; a `*` env name (`NEXT_PUBLIC_*`) expands over the run's environment, as Turbo matches it; `*` is Turbo's only wildcard (`\*`, a leading `\!`, `?` and `[` are literal), and a literal vx cannot key (unkey's `NEXT_PUBLIC_\*`) is left out, reported once only when the run's environment sets it; per-package `turbo.json` overlays apply. A `turbo.jsonc` (Turbo 2.5+) is read wherever a `turbo.json` would be, at the root and in a package; a workspace root with neither is refused, naming the fix. Turbo 2's framework inference (a package on `next` has `NEXT_PUBLIC_*` and `NEXT_DEPLOYMENT_ID` hashed and passed; Turbo's whole table, `expo`, `nuxt`, `remix`, `@sveltejs/kit` and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform's `TURBO_CI_VENDOR_ENV_KEY`) applies too: the prefix expands over the run's environment into that package's tasks, and a task's `!` entry takes names back (`bunx @vzn/vx-migrate`, which writes files for another environment, names each framework and its packages in a note instead). What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.
33
+ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps every package's `build` script the way `turbo run build` would: `dependsOn` edges (`^build`, same-package deps, `pkg#task`), `inputs` as every task's key and `outputs` as `cache: true`, `env` / `passThroughEnv`, `cache: false`, `persistent`. Turbo's global fields (`globalDependencies`, `globalEnv`, `globalPassThroughEnv`, Turbo 1's `globalDotEnv`) are inlined into every task; a `*` env name (`NEXT_PUBLIC_*`) expands over the run's environment, as Turbo matches it; `*` is Turbo's only wildcard (`\*`, a leading `\!`, `?` and `[` are literal), and a literal vx cannot key (unkey's `NEXT_PUBLIC_\*`) is left out, reported once only when the run's environment sets it; per-package `turbo.json` overlays apply. A `turbo.jsonc` (Turbo 2.5+) is read wherever a `turbo.json` would be, at the root and in a package; a directory holding both is refused, as Turbo refuses it, and a workspace root with neither is refused, naming the fix. An absolute path in `inputs`, `outputs`, `globalDependencies` or `global.inputs` is refused naming the file, as Turbo refuses it. Turbo 2's framework inference (a package on `next` has `NEXT_PUBLIC_*` and `NEXT_DEPLOYMENT_ID` hashed and passed; Turbo's whole table, `expo`, `nuxt`, `remix`, `@sveltejs/kit` and the rest, each package taking the first framework it matches, as Turbo does, and leaving out names under the platform's `TURBO_CI_VENDOR_ENV_KEY`) applies too: the prefix expands over the run's environment into that package's tasks, and a task's `!` entry takes names back (`bunx @vzn/vx-migrate`, which writes files for another environment, names each framework and its packages in a note instead). Turbo's builtin env applies as Turbo's source has it: `VERCEL_ANALYTICS_ID` and `VERCEL_TARGET_ENV` are keyed in every task (a `globalEnv` `!` entry such as `!VERCEL*` takes them back), Next's skew rule keys `VERCEL_DEPLOYMENT_ID` when `VERCEL_SKEW_PROTECTION_ENABLED=1`, and the literal names of Turbo's builtin pass-through list (`TZ`, `HOME`, `CI`, the Vercel, Docker and IDE names) reach every task unkeyed. Its wildcard families (`VERCEL_*`, `GITHUB_*`, `DOCKER_*` and the rest) are not matched against the run's environment: `exec.env.passThrough` is keyed by name, so a live match keyed each machine apart. The written config keys the defaults and `VERCEL_DEPLOYMENT_ID` always (a miss where Turbo would hit, never a stale hit) and passes the same literal names from the preset; a note names the wildcard families to add where a task reads them. What runs is what a migration would have written, minus the file: the key a task derives here equals the key the written config would derive.
34
34
 
35
35
  | Option | Meaning |
36
36
  | ------ | ----------------------------------------------------------------------------------------------------------- |
@@ -62,7 +62,7 @@ Rules:
62
62
  - A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: a keyed group (no command, nothing spawns) keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` of their own). A dependency without the script whose own edges lack `^x` gets it too, so a `^x` stops there as Turbo's does (opencode's `build: { dependsOn: [] }`: core walked past to the builds below, which Turbo's `^build` never reaches).
63
63
  - `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
64
64
  - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx hashes each dependency's key into it); from a `cache: false` producer (vercel/vercel's `//#generate:cache-keys`, which records the host) the named files, or its outputs, are a `inputs.workspaceRuntime` probe, and `inputs.tasks` leaves the producer out, so core answers the probe after it ran. Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too (Turbo hashes `globalDependencies` apart from a task's own inputs, so neither's `!` reaches the other's files, and vx subtracts each `!` from the whole list: a `!` that may reach the other side's files is dropped, which keys more, never a stale hit; Turbo 2.11's `global.inputs` joins each task's inputs, where its `!` entries apply), and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A wildcard `X/**` input, `!` or not, also lists `X`, as outputs do: Turbo's `**` matches zero segments, so `*/**` keys on the package-root files too. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
65
- - `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). A wildcard `X/**` also lists `X`: Turbo's `**` matches zero segments, so medusa's `*/**` caches the package-root `tsconfig.tsbuildinfo` too (its `package.json` is taken back, below); a literal `dist/**` needs no `dist`. An output whose first segment is a wildcard (`**/*.d.ts`, medusa's `*/**`) stays cached, as in `nx()` (`turbo()` 2026-10-14): Turbo never cleans an output, vx cleans it before every run and every restore, and such a glob can reach the sources; core never cleans a committed file, and every untracked file it reaches is deleted, which `vx lock`'s warnings name (`output "*/**": … every untracked file this glob reaches is deleted`). Negations beside it (`!src/**`) keep what they name. Without git the files cannot be told apart, and the task runs uncached with a todo. A glob that reaches no tracked kind gets no warning: one segment with a literal extension the package tracks no file of (`*.xml`), `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`), and a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) takes the manifest back with `!package.json` and keeps the cache, in `turbo()` as in `nx()` (2026-10-03): vx would otherwise delete it before every run. The build rewrites a file it may list as an input (`@vzn/vx-lockfile` lists `package.json`), so the Turbo mapping also sets `inputStability: { strategy: 'mutable' }` and an identical rewrite counts as unchanged. So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached the same way (2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) needs nothing: core never cleans a file git tracks, so it survives and the task keeps its cache. A task whose command runs `tsc` that writes a `.tsbuildinfo` (`-b`, `composite` or `incremental`, read through the tsconfig's `extends` and, for `-b`, its `references`) outside its outputs also outputs that file: tsc re-emits only what its buildinfo calls changed, so with `rootDir: "src"` and `outDir: "dist"` its `tsconfig.tsbuildinfo` beside the tsconfig survived the clean before a miss and the miss cached a `dist/` holding only the edited file. Nx infers the file as an output; Turbo never cleans `dist/`.
65
+ - `outputs`: `$TURBO_ROOT$/<path>` → `outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). A wildcard `X/**` also lists `X`: Turbo's `**` matches zero segments, so medusa's `*/**` caches the package-root `tsconfig.tsbuildinfo` too (its `package.json` is taken back, below); a literal `dist/**` needs no `dist`. An output whose first segment is a wildcard (`**/*.d.ts`, medusa's `*/**`) stays cached, as in `nx()` (`turbo()` 2026-10-14): Turbo never cleans an output, vx cleans it before every run and every restore, and such a glob can reach the sources; core never cleans a committed file, and every untracked file it reaches is deleted, which `vx lock`'s warnings name (`output "*/**": … every untracked file this glob reaches is deleted`). Negations beside it (`!src/**`) keep what they name. Without git the files cannot be told apart, and the task runs uncached with a todo. A glob that reaches no tracked kind gets no warning: one segment with a literal extension the package tracks no file of (`*.xml`), `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`), and a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) takes the manifest back with `!package.json` and keeps the cache, in `turbo()` as in `nx()` (2026-10-03): vx would otherwise delete it before every run. The build rewrites a file it may list as an input (`@vzn/vx-lockfile` lists `package.json`), so the Turbo mapping also sets `inputs.stability: { strategy: 'mutable' }` and an identical rewrite counts as unchanged. So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached the same way (2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) needs nothing: core never cleans a file git tracks, so it survives and the task keeps its cache. A task whose command runs `tsc` that writes a `.tsbuildinfo` (`-b`, `composite` or `incremental`, read through the tsconfig's `extends` and, for `-b`, its `references`) outside its outputs also outputs that file: tsc re-emits only what its buildinfo calls changed, so with `rootDir: "src"` and `outDir: "dist"` its `tsconfig.tsbuildinfo` beside the tsconfig survived the clean before a miss and the miss cached a `dist/` holding only the edited file. Nx infers the file as an output; Turbo never cleans `dist/`.
66
66
  - A `**` glued to text in an input, output or `globalDependencies` glob is read as Turbo reads it (turborepo#5431): `src/**.ts` → `src/**/*.ts`, `gen**` → `gen*/**`. Core refuses the glued spelling.
67
67
  - `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.
68
68
  - 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.
@@ -70,16 +70,21 @@ Rules:
70
70
  - **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.
71
71
  - **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.
72
72
  - **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.
73
- - `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
+ - `cache: false` or `persistent: true` → no `cache: true`, 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`.
74
74
  - A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
75
75
  - `outputLogs: "new-only"` maps to nothing: frames for the tasks that ran and a one-liner per cache hit is vx's default flow already. The other values are per-run in vx, so they are a todo naming the flag (`vx run … --output-logs hash-only`).
76
76
  - `interruptible` maps to nothing: `vx watch` keeps a persistent task up across cycles and restarts it only when its config changes or it dies.
77
- - A task's `tags` (Turbo main, after 2.11.5) map to nothing: Turbo keeps them out of the hash and the run.
77
+ - A package's own turbo.json `tags` (Turbo 2.11.6) are its vx project `tags`, so `vx run build --filter tag:ci` selects what `turbo run build --filter=tag:ci` does through package tags (the root's label the root project, none inherited, as in Turbo). A task's `tags` map to nothing: vx tags are a project's, so a `tag:` filter never reaches a task by its own tags.
78
+ - A task's `experimentalCI` maps to nothing: Turbo reads it for external CI tooling and acts on nothing.
78
79
  - `envMode: "loose"` (top-level or in `global`, or `TURBO_ENV_MODE=loose`, which wins) is a note: Turbo hands every task the whole environment, vx only the declared names.
79
80
  - The workspace keys vx has a home for, top level or in `global`, fill what `.vx/workspace.ts` leaves unset: `run.concurrency` (`"10"`, `"50%"` of the cores) → `run.concurrency`. `TURBO_CONCURRENCY` wins over turbo.json, as under Turbo; a set one decides, its `0` included. `cacheMaxSize` / `cacheMaxAge` map to nothing: no vx run evicts, `vx cache clean --max-size / --older-than` does.
80
- - `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is the run's base commit (`setBranch({ base })`): a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`.
81
+ - `TURBO_FORCE=true` (or `1`), `turbo run --force`, turns every cache read off through the workspace's `cache.policy`: each task runs and refreshes the local cache. The remote is written only where `.vx/workspace.ts`'s own `cache.policy` names it, so a laptop's forced run never writes the cache CI reads; `vx run --force` is the flag. A `--cache-policy` or `VX_CACHE_POLICY` replaces the workspace's policy, this included.
82
+ - `TURBO_SCM_BASE`, the base `turbo run --affected` compares with, is the run's base commit (`setBranch({ base })`): a bare `vx run build --affected` compares with the same ref. With it unset on GitHub Actions (`GITHUB_ACTIONS`), Turbo's own base applies: a pull request's `GITHUB_BASE_REF`, else the push event's `before` from `GITHUB_EVENT_PATH`. `TURBO_SCM_HEAD`, the commit Turbo compares up to, is vx's checkout: `--affected` compares the base with HEAD and its working tree, so a head at HEAD's commit (`$GITHUB_SHA` on a checkout of it) is honoured as is, and any other one is a warning to check that commit out.
81
83
  - Turbo hashes each package's `microfrontends.json` (or `.jsonc`) into every task, or the one file `VC_MICROFRONTENDS_CONFIG_FILE_NAME` names; so does `turbo()`.
84
+ - `TURBO_ROOT_TURBO_JSON`, Turbo's root config path (against the cwd), is refused when it names another file than the workspace root's `turbo.json` or `turbo.jsonc`: `turbo()` reads only that one.
85
+ - Turbo's run switches the plugins do not read, on purpose: `TURBO_CACHE`, `TURBO_REMOTE_CACHE_READ_ONLY` and `TURBO_REMOTE_ONLY` (core's `cache.policy` decides); `TURBO_CACHE_DIR`, `TURBO_CACHE_MAX_AGE` and `TURBO_CACHE_MAX_SIZE` (`cache.dir`; no run evicts); `TURBO_LOG_ORDER`, `TURBO_UI`, `TURBO_EXPERIMENTAL_UI`, `TURBO_TUI_SCROLLBACK_LENGTH` and `TURBO_LOG_VERBOSITY` (vx frames each task's output its own way); `TURBO_RUN_SUMMARY` and `TURBO_LOG_FILE` (`vx last`, telemetry plugins); `TURBO_EXPERIMENTAL_OTEL_*` (`@vzn/vx-otel`); `TURBO_DAEMON` (no daemon); `TURBO_DANGEROUSLY_DISABLE_PACKAGE_MANAGER_CHECK` (no such check); `TURBO_ALLOW_NO_TURBO_JSON` (`turbo()` maps a turbo.json and refuses a root with none); `TURBO_PLATFORM_ENV` and `TURBO_PLATFORM_ENV_DISABLED` (Turbo warns of CI variables strict mode drops; a vx task never sees an undeclared variable, and no run warns); `TURBO_LOGIN`, `TURBO_SSO_LOGIN_CALLBACK_PORT` and `TURBO_CONFIG_DIR_PATH` (no login verb and no user config file: the token comes as [Tokens](#tokens-and-the-os-keychain) says); `TURBO_NO_UPDATE_NOTIFIER`, `TURBO_PRINT_VERSION_DISABLED`, `TURBO_GLOBAL_WARNING_DISABLED` and `TURBO_DOWNLOAD_LOCAL_ENABLED` (the `turbo` binary's own; vx runs none).
82
86
  - Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) is read as the `globalDependencies`, `globalEnv` and `globalPassThroughEnv` it replaces, except that a task's `!` input takes a `global.inputs` file back, as Turbo prepends them to each task's inputs.
87
+ - The root `package.json`'s `engines` (a non-empty object), which Turbo hashes into every task's key: each task's `inputs.workspaceRuntime` holds one `awk` line that prints that value alone, compacted, at the workspace root, named `rootEngines` in the written `vx-preset.ts`. `turbo()` and the written config carry the same line, so they key alike; raising `engines.node` re-runs every task, and no other root edit re-keys one. A reformat keys alike; reordering its keys misses (Turbo's map would hit). A config written while the root declared no `engines` keys none added later.
83
88
  - An unknown Turbo key is a todo naming it; `extends` is accepted and ignored (the overlay order above is what it means).
84
89
 
85
90
  ## `nx()` — an Nx repo, mid-migration
@@ -92,7 +97,7 @@ import { nx } from '@vzn/vx-migrate'
92
97
  export default defineWorkspace({ plugins: [nx()] })
93
98
  ```
94
99
 
95
- 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 hashes 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; with `dependencies: true` beside it, both the listed projects' dependencies' input (Nx 24's reading) and this project's `^input` (Nx 23's) are keyed. 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 goes 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) is keyed 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`.
100
+ 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 hashes 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 `cache: true`, 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; with `dependencies: true` beside it, both the listed projects' dependencies' input (Nx 24's reading) and this project's `^input` (Nx 23's) are keyed. 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 goes 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) is keyed 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`.
96
101
 
97
102
  | Option | Meaning |
98
103
  | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -253,10 +258,10 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
253
258
  | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
254
259
  | `dependsOn` | `dependsOn`, same micro-syntax (`$TURBO_ROOT$` deps are a TODO) |
255
260
  | `inputs` | `inputs.files` (`$TURBO_DEFAULT$` → `**/*`; `$TURBO_ROOT$/x` → `workspaceFiles`) |
256
- | `outputs` | `cache.outputs.files`, a negated one included |
261
+ | `outputs` | `outputs.files`, a negated one included |
257
262
  | `env` | `inputs.env` **and** `exec.env.passThrough` (child envs are isolated) |
258
263
  | `passThroughEnv` | `exec.env.passThrough` |
259
- | `cache: false` / `persistent: true` | no `cache` block, still keyed on its `inputs`; `exec.persistent: {}`, with a TODO to set `readyWhen` when a task depends on it |
264
+ | `cache: false` / `persistent: true` | no `cache: true`, still keyed on its `inputs`; `exec.persistent: {}`, with a TODO to set `readyWhen` when a task depends on it |
260
265
 
261
266
  ### Nx
262
267
 
@@ -266,19 +271,19 @@ Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.jso
266
271
 
267
272
  Detected when the root `package.json` lists `vite-plus`, after turbo.json and Nx: vite-plus is a whole toolchain, so a repo with either of those runs its tasks there; `--from vite-task` picks Vite Task anyway. There is no live plugin, so `--keep` is refused. Each package's `vite.config.*` (vite-plus's file order) is loaded by Bun as `vp run` sees it, a function config called in build mode, and its `run` block mapped with the package's `package.json` scripts, which `vp run` runs too. The root package's tasks and scripts (`vp run -w`) make it a project when it has a `name`. A command that opens with `vp run` (or `vpr`) is inlined as Vite Task inlines it: `vp run x`, `pkg#x`, `-r x`, `-w x` and `-F <name> x` are `dependsOn` edges (`-r` to each package with `x`, the task's own reference pruned), and the task is a group when nothing follows them. They run in parallel where Vite Task ran a chain in order; any other form (`--parallel`, `-t`, forwarded arguments, a `vp run` after another command) stays in the command with a TODO.
268
273
 
269
- | Vite Task | vx |
270
- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
271
- | `command` (a string, or an array run in order) | `exec.command`, the array joined with `&&`; `command: []` is a group task |
272
- | `cwd` | `cd <cwd> && ` before the command |
273
- | `dependsOn: 'x'` / `'pkg#x'` | the same |
274
- | `dependsOn: { task, from }` | `^task`; when a member listed under a field `from` leaves out also has the task, the explicit `pkg#task` edges, since `^` follows every field |
275
- | `cache.input` / `cache.output` globs | `inputs.files` / `cache.outputs.files`; `base: 'workspace'` → `workspaceFiles`, a root task's too; a `**` glued to text (`src/**.ts`), which wax refuses, widens an input to `**/*` and runs an output's task uncached, with a todo |
276
- | omitted or `{ auto: true }` (traced files) | no `cache` block, with a TODO: vx infers no inputs or outputs |
277
- | `cache.env` | `inputs.env` **and** `exec.env.passThrough`; a `*` entry lists the names the package's tracked files and its workspace dependencies' spell (a TODO when none match); `!` entries take names back |
278
- | `cache.untrackedEnv` | `exec.env.passThrough`, wildcards as `cache.env` |
279
- | `cache: false`, root `run.cache` `false` / `tasks: false` | no `cache` block |
280
- | a `package.json` script | a task, uncached; with root `run.cache.scripts` a TODO for its cache |
281
- | `run.enablePrePostScripts` (default `true`) | a script's `pre<name>` / `post<name>` joined into its command; `false` keeps them tasks of their own |
274
+ | Vite Task | vx |
275
+ | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
276
+ | `command` (a string, or an array run in order) | `exec.command`, the array joined with `&&`; `command: []` is a group task |
277
+ | `cwd` | `cd <cwd> && ` before the command |
278
+ | `dependsOn: 'x'` / `'pkg#x'` | the same |
279
+ | `dependsOn: { task, from }` | `^task`; when a member listed under a field `from` leaves out also has the task, the explicit `pkg#task` edges, since `^` follows every field |
280
+ | `cache.input` / `cache.output` globs | `inputs.files` / `outputs.files`; `base: 'workspace'` → `workspaceFiles`, a root task's too; a `**` glued to text (`src/**.ts`), which wax refuses, widens an input to `**/*` and runs an output's task uncached, with a todo |
281
+ | omitted or `{ auto: true }` (traced files) | no `cache: true`, with a TODO: vx infers no inputs or outputs |
282
+ | `cache.env` | `inputs.env` **and** `exec.env.passThrough`; a `*` entry lists the names the package's tracked files and its workspace dependencies' spell (a TODO when none match); `!` entries take names back |
283
+ | `cache.untrackedEnv` | `exec.env.passThrough`, wildcards as `cache.env` |
284
+ | `cache: false`, root `run.cache` `false` / `tasks: false` | no `cache: true` |
285
+ | a `package.json` script | a task, uncached; with root `run.cache.scripts` a TODO for its cache |
286
+ | `run.enablePrePostScripts` (default `true`) | a script's `pre<name>` / `post<name>` joined into its command; `false` keeps them tasks of their own |
282
287
 
283
288
  ### package.json scripts
284
289
 
@@ -348,10 +353,10 @@ fails leaves the files as written and the note names its error.
348
353
  Each script becomes a task with its command verbatim. `build` gets
349
354
  `dependsOn: ['^build']` (a `build` that only delegates, `pnpm run
350
355
  compile`, is a group, and the edge goes on the script that does the
351
- work) and **no cache block** — under a
356
+ work) and **no `cache: true`** — under a
352
357
  `TODO(vx-migrate)` showing the block to add with the package's real
353
358
  inputs and outputs, and the same block commented as the task's first
354
- line: set the real globs and delete the `// `. A task without a cache block always runs; a block
359
+ line: set the real globs and delete the `// `. A task without `cache: true` always runs; a block
355
360
  with EMPTY outputs is not "uncached" but a no-output task that hits on
356
361
  unchanged inputs and skips the build with nothing to restore (a deleted
357
362
  `dist` stays deleted under a green `up-to-date` run — what `vx init`
@@ -552,7 +557,7 @@ export default defineWorkspace({
552
557
 
553
558
  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()`.
554
559
 
555
- Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. An empty variable is unset, as Turbo reads it. A token with no URL 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 URL 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.
560
+ Every option falls back to the tool's own environment variable, so a self-hosted setup carries over unchanged. An empty variable is unset, as Turbo reads it. With no URL the cache is Vercel's hosted Remote Cache (`https://vercel.com/api`), exactly as 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`; one that is not a JSON object is named in a warning and read as absent, where Turbo refuses it), then the root `turbo.json`'s `remoteCache` (`apiUrl`, `teamId`, `teamSlug`), whose `enabled: false` declines unless the options name a cache. As for `turbo`, these sources link a cache only with a team: a token without one is named in a warning, and with no team the plugin **declines** and the run stays local. A team with no token takes the token from the OS keychain ([Tokens](#tokens-and-the-os-keychain)). A `url`, `token` or `oidc` option names a cache with or without a team.
556
561
 
557
562
  | Option | Environment variable | Meaning |
558
563
  | ----------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -632,7 +637,7 @@ With no token from an option, a variable or a config file, both plugins read the
632
637
  - Three outages in a row (no connection, no answer within the deadline, or a `5xx` once the resends are spent) open Turbo's outage breaker: no request is sent for 30 s, each lookup is a miss at once, then one request probes and its answer closes or reopens it. A hit is judged when its body ends, so a server that sends headers and then stalls the body counts the same way. A hung server costs three deadlines, not one per task.
633
638
  - A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (a `403` on an upload is a read-only token — Nx's spec, or turborepo-remote-cache's `READ_ONLY` and write-less JWTs: it turns off uploads alone, and reads go on) — including the requests already in flight when the refusal lands, which degrade in silence rather than repeating it (a six-project run printed five identical lines before 2026-09-20).
634
639
  - Policy (`--cache-policy=remote:r`, `VX_CACHE_POLICY`, `cache.policy`) is enforced by core, which layers each plugin's remote over the local cache — a read-only token pairs naturally with `remote:r`.
635
- - Core's policy alone decides reads and writes: the plugins read none of Turbo's or Nx's own switches (`TURBO_CACHE`, `TURBO_REMOTE_CACHE_READ_ONLY`, `NX_SKIP_REMOTE_CACHE`, `NX_DISABLE_REMOTE_CACHE`). A CI that keeps untrusted pull requests off the shared cache sets `cache.policy` or `--cache-policy=local:rw,remote:r` for them.
640
+ - Core's policy alone decides reads and writes: the plugins read none of Turbo's or Nx's own switches (`TURBO_CACHE`, `TURBO_REMOTE_ONLY`, `TURBO_REMOTE_CACHE_READ_ONLY`, `NX_SKIP_REMOTE_CACHE`, `NX_DISABLE_REMOTE_CACHE`); `turbo()` reads `TURBO_FORCE`, as `--force`. A CI that keeps untrusted pull requests off the shared cache sets `cache.policy` or `--cache-policy=local:rw,remote:r` for them.
636
641
 
637
642
  ## Testing
638
643
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.650",
3
+ "version": "0.0.652",
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.650"
43
+ "@vzn/vx": "^0.0.652"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
package/src/adopt.ts CHANGED
@@ -6,6 +6,9 @@
6
6
 
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
9
+ // By namespace: bin.ts loads this before its release check, and a core
10
+ // with no `ui` must still reach that refusal.
11
+ import * as vx from '@vzn/vx'
9
12
  import { UserError } from '@vzn/vx'
10
13
 
11
14
  export type AdoptionMode = 'native' | 'keep'
@@ -270,8 +273,7 @@ async function runManager(
270
273
  const line = argv.join(' ')
271
274
  // The report's `install` row says what ran; a terminal sees it while it
272
275
  // runs, on a line the report then takes the place of.
273
- const live = process.stdout.isTTY === true
274
- if (live) process.stdout.write(`\n ${line.slice(0, (process.stdout.columns ?? 80) - 4)} …`)
276
+ const clear = vx.ui.transient(line)
275
277
  // The manager's own output (progress, deprecations, audit, postinstalls)
276
278
  // is shown only when it fails: mid-init it buried the report (Growth's
277
279
  // first-run funnel F3, 2026-10-13).
@@ -300,9 +302,9 @@ async function runManager(
300
302
  failed = `: ${(e2 as Error).message}`
301
303
  }
302
304
  }
303
- if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
305
+ clear()
304
306
  if (code !== 0) {
305
- process.stderr.write(said)
307
+ vx.ui.data('err', said)
306
308
  const missing = notServed(said)
307
309
  throw new UserError(
308
310
  missing === undefined
package/src/bin.ts CHANGED
@@ -27,6 +27,9 @@ try {
27
27
  const { migrateCmd } = await import('./migrate.js')
28
28
  process.exitCode = await migrateCmd(process.argv.slice(2))
29
29
  } catch (err) {
30
+ // `ui` by namespace: a core older than it has none, and its refusal
31
+ // still reaches the reader.
32
+ const ui = (core as Partial<typeof core>).ui
30
33
  // Asked for JSON (`vx init --dry --format json` hands a Turbo or Nx repo
31
34
  // here), a refusal is core's error document on stdout too
32
35
  // (schemas/error.json): stdout was empty.
@@ -38,14 +41,11 @@ try {
38
41
  const docs = code.startsWith('VX_E_')
39
42
  ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
40
43
  : {}
41
- process.stdout.write(
42
- `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`,
43
- )
44
+ const doc = `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`
45
+ if (ui === undefined) process.stdout.write(doc)
46
+ else ui.data('out', doc)
44
47
  }
45
48
  if (isUserError(err)) {
46
- // `ui` by namespace: a core older than it has none, and its refusal
47
- // still reaches the reader.
48
- const ui = (core as Partial<typeof core>).ui
49
49
  if (ui === undefined) process.stderr.write(`vx-migrate: ${err.message}\n`)
50
50
  else ui.say('error', `vx-migrate: ${err.message}`)
51
51
  process.exitCode = 1
@@ -118,7 +118,7 @@ export function cacheTodo(command: string): string {
118
118
  const words = ` ${command.replace(/[;&|()]/g, ' ')} `
119
119
  const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
120
120
  const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
121
- return `cache: add \`inputs: { files: ['src/**'] }, cache: { outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs, and so does every task that depends on it; a block with EMPTY outputs would be a cached no-op, not an uncached task`
121
+ return `cache: add \`inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] }, cache: true\` with this package's real inputs and outputs — without it the task always runs, and so does every task that depends on it; a block with EMPTY outputs would be a cached no-op, not an uncached task`
122
122
  }
123
123
 
124
124
  // `lint` is not here: a linter reads sources, and an edge to `build`
@@ -986,7 +986,7 @@ export function migrateScripts(
986
986
  // Where to add one is the TODO's to say, when there is one: an Nx repo
987
987
  // whose only script is `codegen` read "`build` carries a TODO" over
988
988
  // "0 TODOs" (the first-five-minutes walk, 2026-09-28).
989
- 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block',
989
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got `cache: true`',
990
990
  ],
991
991
  projects,
992
992
  extraFiles: [],
@@ -14,6 +14,7 @@ import {
14
14
  import type { PackageMeta } from './packages.js'
15
15
  import { mapTurboWorkspace, type TurboGlobal, type TurboMappedProject } from './turbo/turbo-map.js'
16
16
  import { rootTaskProject } from './turbo/turbo-reads.js'
17
+ import { ROOT_ENGINES_PROBE } from './turbo/root-engines.js'
17
18
  import { relPosix } from './paths.js'
18
19
  import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
19
20
  import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
@@ -86,6 +87,7 @@ export async function migrateTurbo(
86
87
  return {
87
88
  name: p.name,
88
89
  dir: p.dir,
90
+ ...(p.tags !== undefined ? { tags: p.tags } : {}),
89
91
  importLines: [
90
92
  ...(readsManifest ? [MANIFEST_IMPORT] : []),
91
93
  ...(sealed
@@ -246,10 +248,11 @@ function identifier(task: string): string {
246
248
  }
247
249
 
248
250
  /**
249
- * The `.env` probes, by name: Turbo hashes a task's `.env` files although
250
- * git ignores them, and a glob over git's files sees none, so the mapper
251
- * keys them through a shell line that prints each file. Inline in every
252
- * package's config, that line read as noise nobody could review.
251
+ * The probes, by name: Turbo hashes a task's `.env` files although git
252
+ * ignores them, and a glob over git's files sees none, so the mapper keys
253
+ * them through a shell line that prints each file; and the root's
254
+ * `engines`, which Turbo folds into every task. Inline in every package's
255
+ * config, each line read as noise nobody could review.
253
256
  */
254
257
  type Probe = { name: string; command: string }
255
258
 
@@ -262,6 +265,10 @@ const PROBES: readonly Probe[] = [
262
265
  name: 'dotenvFilesDeep',
263
266
  command: DOTENV_PROBE,
264
267
  },
268
+ {
269
+ name: 'rootEngines',
270
+ command: ROOT_ENGINES_PROBE,
271
+ },
265
272
  ]
266
273
 
267
274
  function nameProbes(projects: readonly TurboMappedProject[]): {
package/src/migrate.ts CHANGED
@@ -429,7 +429,7 @@ async function lockWorkspace(root: string): Promise<{ code: number; said: string
429
429
  // A success's warnings print above the report; a refusal is said whole.
430
430
  // A success is a report row, matched as text: the colour FORCE_COLOR
431
431
  // gave the child would hide its `✓ vx lock` mark from that match.
432
- if (code === 0) process.stderr.write(err)
432
+ if (code === 0) ui.data('err', err)
433
433
  return { code, said: code === 0 ? Bun.stripANSI(out) : out + err }
434
434
  }
435
435
 
@@ -613,7 +613,8 @@ async function keep(
613
613
  ...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])]),
614
614
  ])
615
615
  if (args.json) {
616
- process.stdout.write(
616
+ ui.data(
617
+ 'out',
617
618
  `${JSON.stringify({
618
619
  dry: true,
619
620
  source,
@@ -637,7 +638,7 @@ async function keep(
637
638
  // A run reads only the lock; with the plugins installed it is written
638
639
  // now, else `next` names `vx lock` after the install.
639
640
  const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
640
- if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
641
+ if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
641
642
  const rows: ui.FieldRow[] = [
642
643
  ['from', `${source}, run by ${runner}() from @vzn/vx-migrate`],
643
644
  ...(args.dry ? [['dry run', 'nothing written'] as const] : []),
package/src/nx/nx-map.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  resolveSharedWorkspaceOutputs,
35
35
  wildcardOutput,
36
36
  wildcardTodo,
37
+ uncache,
37
38
  } from '../shared-outputs.js'
38
39
  import type { TrackedKinds } from '../tracked-outputs.js'
39
40
  import { isRecord, packageScripts, relPosix } from '../paths.js'
@@ -181,6 +182,7 @@ function checkNxNodes(nodes: Record<string, unknown>, label: string): void {
181
182
  const data = (n['data'] ?? {}) as Record<string, unknown>
182
183
  optional(data['root'], `${at}.data.root`, (v) => typeof v === 'string', 'a string')
183
184
  optional(data['targets'], `${at}.data.targets`, isRecord, 'an object of targets')
185
+ checkNamedInputs(data['namedInputs'], `${at}.data.namedInputs`, label)
184
186
  for (const [name, target] of Object.entries((data['targets'] ?? {}) as object)) {
185
187
  const t = `${at}.data.targets.${JSON.stringify(name)}`
186
188
  if (!isRecord(target)) refuse(t, 'an object')
@@ -195,6 +197,19 @@ function checkNxNodes(nodes: Record<string, unknown>, label: string): void {
195
197
  }
196
198
  }
197
199
 
200
+ /**
201
+ * `namedInputs` as the mapper expands them: each name a list of inputs.
202
+ * A name holding an object or a number reached the expansion's loop and
203
+ * printed `TypeError: {} is not iterable` with its stack (fuzzed).
204
+ */
205
+ function checkNamedInputs(v: unknown, at: string, label: string): void {
206
+ if (v === undefined) return
207
+ if (!isRecord(v)) throw new UserError(`${label}: ${at} must be an object of named inputs`)
208
+ for (const [name, members] of Object.entries(v))
209
+ if (!Array.isArray(members) || members.some((x) => typeof x !== 'string' && !isRecord(x)))
210
+ throw new UserError(`${label}: ${at}.${JSON.stringify(name)} must be an array of inputs`)
211
+ }
212
+
198
213
  export interface MapNxOptions {
199
214
  /** nx.json's legacy `cacheableOperations`, for a graph whose targets carry no `cache` field. */
200
215
  readonly cacheable: ReadonlySet<string>
@@ -582,7 +597,7 @@ export async function mapNxWorkspace(
582
597
  dropUnheldDeps(t, meta.name, emitted, emittedIds)
583
598
  const reader = dependentReaders.get(t)
584
599
  if (reader !== undefined && !reachesProducer(t, meta.name, emittedIds, reached)) {
585
- delete t.task!['cache']
600
+ uncache(t.task!)
586
601
  t.todos.push(orphanReaderTodo(reader.globs, reader.dependsOn))
587
602
  }
588
603
  }
@@ -649,10 +664,7 @@ function resolveAtomizedWorkspaceOutputs(
649
664
  const cached = (ps: readonly { name: string; dir: string; tasks: GeneratedTask[] }[]): number => {
650
665
  const copy = structuredClone(ps.map((p) => ({ name: p.name, dir: p.dir, tasks: p.tasks })))
651
666
  resolveSharedWorkspaceOutputs(root, copy)
652
- return copy.reduce(
653
- (n, p) => n + p.tasks.filter((t) => t.task?.['cache'] !== undefined).length,
654
- 0,
655
- )
667
+ return copy.reduce((n, p) => n + p.tasks.filter((t) => t.task?.['cache'] === true).length, 0)
656
668
  }
657
669
  const better = split.size > 0 && cached(last) > cached(projects)
658
670
  resolveSharedWorkspaceOutputs(root, better ? last : projects)
@@ -743,6 +755,7 @@ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
743
755
  sync?: { globalGenerators?: unknown }
744
756
  }
745
757
  const named = parsed?.namedInputs
758
+ checkNamedInputs(named ?? undefined, 'namedInputs', 'nx.json')
746
759
  const ops = parsed?.tasksRunnerOptions?.default?.options?.cacheableOperations
747
760
  return {
748
761
  namedInputs:
@@ -754,7 +767,8 @@ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
754
767
  ? parsed.sync.globalGenerators.filter((g): g is string => typeof g === 'string')
755
768
  : [],
756
769
  }
757
- } catch {
770
+ } catch (err) {
771
+ if (err instanceof UserError) throw err
758
772
  // An unreadable nx.json (or base) just degrades named-input refs to TODOs.
759
773
  return none
760
774
  }
@@ -1045,7 +1059,8 @@ function buildTask(
1045
1059
  if (cacheEnabled) {
1046
1060
  const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
1047
1061
  if (wsOutFiles.length > 0) outputs.workspaceFiles = takingBack(wsOutFiles)
1048
- task.cache = { outputs }
1062
+ task.outputs = outputs
1063
+ task.cache = true
1049
1064
  }
1050
1065
 
1051
1066
  const generated: GeneratedTask = { name: variant.name, todos, task }
package/src/package-fs.ts CHANGED
@@ -40,19 +40,35 @@ export function normalizeBunGlob(glob: string): string {
40
40
  return neg ? `!${g}` : g
41
41
  }
42
42
 
43
+ /**
44
+ * Where the `[`…`]` class opening at `i` ends (a `]` right after the `[` is
45
+ * a member); `i` for a `[` with no `]`. A `,` `{` or `}` in a class is a
46
+ * member, never a brace cut, as `Bun.Glob` reads it.
47
+ */
48
+ function classEnd(pattern: string, i: number): number {
49
+ const end = pattern.indexOf(']', i + 2)
50
+ return end === -1 ? i : end
51
+ }
52
+
43
53
  /**
44
54
  * `pattern` with its first brace group expanded, recursively, while that
45
55
  * group holds a `/`: `Bun.Glob`'s scan finds nothing for one.
46
56
  */
47
57
  export function slashBraceExpansions(pattern: string): string[] {
48
58
  if (pattern.includes('\\')) return [pattern]
49
- for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
59
+ for (let open = 0; open < pattern.length; open++) {
60
+ if (pattern[open] === '[') {
61
+ open = classEnd(pattern, open)
62
+ continue
63
+ }
64
+ if (pattern[open] !== '{') continue
50
65
  let depth = 0
51
66
  const cuts: number[] = []
52
67
  let close = -1
53
68
  for (let i = open; i < pattern.length && close === -1; i++) {
54
69
  const c = pattern[i]
55
- if (c === '{') depth++
70
+ if (c === '[') i = classEnd(pattern, i)
71
+ else if (c === '{') depth++
56
72
  else if (c === '}' && --depth === 0) close = i
57
73
  else if (c === ',' && depth === 1) cuts.push(i)
58
74
  }
@@ -27,5 +27,10 @@ export async function refusal(res: Response, what = ''): Promise<Error> {
27
27
  } catch {
28
28
  // quiet: the status alone still names the refusal
29
29
  }
30
- return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
30
+ // Each control as `\xNN`: an `ESC ]` in the server's words retitled the terminal (L-31).
31
+ const line = said
32
+ .slice(0, 200)
33
+ // oxlint-disable-next-line no-control-regex
34
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
35
+ return new Error(`${what}HTTP ${res.status}${line === '' ? '' : `: ${line}`}`)
31
36
  }