@vzn/vx-migrate 0.0.595 → 0.0.596

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
@@ -1,10 +1,10 @@
1
1
  # @vzn/vx-migrate
2
2
 
3
- Everything for adopting [`@vzn/vx`](https://github.com/vznjs/vx) from Turborepo or Nx, in one package with zero dependencies:
3
+ Everything for adopting [`@vzn/vx`](https://github.com/vznjs/vx) from Turborepo, Nx or Vite Task, in one package with zero dependencies:
4
4
 
5
5
  - **`turbo()`** — a temporary start for a Turbo repository: the plugin fills vx's `project` stage from `turbo.json` and each package's `package.json` scripts until the migrator writes native config.
6
6
  - **`nx()`** — the same temporary start for an Nx repository, filled from Nx's resolved project graph. Executor targets (`@nx/js:tsc`, `@nx/vite:build`, your own) run as themselves through **`nx-exec`**, one executor per process.
7
- - **`bunx @vzn/vx-migrate`** — write one `vx.config.ts` per workspace package from your `turbo.json`, or an exported Nx project graph, plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second.
7
+ - **`bunx @vzn/vx-migrate`** — write one `vx.config.ts` per workspace package from your `turbo.json`, an exported Nx project graph, or vite-plus's `run.tasks` (Vite Task, `vp run`), plus the workspace file every run needs. Runs without a workspace file, so it is the first command, not the second.
8
8
  - **`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.
9
9
 
10
10
  ```sh
@@ -47,7 +47,7 @@ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps eve
47
47
 
48
48
  ### The mapper (`mapTurboWorkspace`)
49
49
 
50
- The plugin and the CLI read a repo the same way: the mapper the plugin runs live is what `bunx @vzn/vx-migrate` writes `vx.config.ts` per package from, splicing Turbo's global fields in as imports of a generated `vx-preset.ts`, where the plugin inlines the values. `splice` is the seam between the two consumers; `uses` names which globals a task drew on. Before 2026-09-10 the mapper lived in `@vzn/vx` itself; until 2026-09-11 the plugin was its own package, `@vzn/vx-turbo`.
50
+ The plugin and the CLI read a repo the same way: the mapper the plugin runs live is what `bunx @vzn/vx-migrate` writes `vx.config.ts` per package from, splicing Turbo's global fields in as imports of a generated `vx-preset.ts`, where the plugin inlines the values; a global input another task writes to is taken back after the spread (`[...globalInputs, '!packages/plugin/dist/**']`), as the plugin takes it back from the inlined values. `splice` is the seam between the two consumers; `uses` names which globals a task drew on. Before 2026-09-10 the mapper lived in `@vzn/vx` itself; until 2026-09-11 the plugin was its own package, `@vzn/vx-turbo`.
51
51
 
52
52
  Rules:
53
53
 
@@ -60,7 +60,7 @@ Rules:
60
60
  - `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.
61
61
  - `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 folds each dependency's key); from a `cache: false` producer (vercel/vercel's `//#generate:cache-keys`, which records the host) the named files, or its outputs, are a `cache.inputs.workspaceRuntime` probe, and `cache.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>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, 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 `cache.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 `cache.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 `.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 (`cache.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.
62
62
  - `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). An output whose first segment is a wildcard (`**/*.d.ts`, `*/**`) runs the task uncached with a todo: Turbo never cleans an output, vx cleans it before every run, and such a glob reaches the sources. One segment with a literal extension the package tracks no file of (`*.xml`, `*.tsbuildinfo`) reaches only top-level artifacts and stays cached, and so does `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`; `node_modules` is never an output), and so does a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). A wildcard first segment with a literal rest (clerk's `*/package.json`, the subpath stubs it commits) reaches only files of that name: each committed one, up to 16, is taken back with `!` and the task stays cached; more, and it runs uncached. An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) runs the task uncached with a todo: vx would delete the manifest before every run, and core refuses that config. `nx()` takes the manifest back with `!package.json` instead and keeps the cache (owner, 2026-10-03). 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 instead: each committed file it reaches is taken back with `!` (past sixteen, uncached with a todo), and the clean removes the rest, what the task writes (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) is taken back with `!` — Turbo and Nx never clean an output, vx does — so it survives the clean and the task keeps its cache; past sixteen such files the task runs uncached with a todo. `turbo()` and `nx()` both do this, and so do the configs `vx-migrate` writes from turbo.json or an Nx graph (each take-back is a todo there, as it names files a later commit may move).
63
- - `env` / `passThroughEnv`: explicit names go to `cache.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.
63
+ - `env` / `passThroughEnv`: explicit names go to `cache.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.
64
64
  - 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.
65
65
 
66
66
  - **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 — the task with a `^` edge keeps its cache (the first declared when none has one); the rest run uncached with a todo naming the keeper and the fix, their own output path. Same rule for Nx targets.
@@ -88,7 +88,7 @@ import { nx } from '@vzn/vx-migrate'
88
88
  export default defineWorkspace({ plugins: [nx()] })
89
89
  ```
90
90
 
91
- 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. 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. A target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `inputs` / `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 each committed file it reaches taken back by `!` and the rest cleaned before the run (typescript-eslint's `**/*.shot`, 3,656 committed snapshots, is past sixteen and runs uncached with a todo), 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, `{ runtime }` inputs run at the workspace root as Nx runs them (`cache.inputs.workspaceRuntime`), 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 — `true`, cached, 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. The twins show in a run's output; each costs about 0.16 ms of a warm run. 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`.
91
+ 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. 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. A target Nx caches (`cache: true`, or the legacy `cacheableOperations` list in `nx.json`) gets its `inputs` / `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 each committed file it reaches taken back by `!` and the rest cleaned before the run (typescript-eslint's `**/*.shot`, 3,656 committed snapshots, is past sixteen and runs uncached with a todo), 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 (`cache.inputs.workspaceRuntime`), 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 — `true`, cached, 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. The twins show in a run's output; each costs about 0.16 ms of a warm run. 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`.
92
92
 
93
93
  | Option | Meaning |
94
94
  | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -166,6 +166,10 @@ Two cached tasks on one workspace path cannot both keep their cache, and the fir
166
166
 
167
167
  `maxCacheSize` (or `NX_MAX_CACHE_SIZE`, above it as in Nx) is the run's `cacheRetention.maxSize` when `vx.workspace.ts` sets no retention, in Nx's grammar (`10GB`, `1.5 GB`, bare bytes); `0` is no cap.
168
168
 
169
+ ### Lerna
170
+
171
+ `lerna run build` (Lerna 6+ runs it on Nx's task runner) runs each package's `build` after its dependencies' `build`, unless the repo configures Nx's task dependencies: nx.json `targetDefaults` (or the legacy `targetDependencies`), or an `nx` key in the `package.json` of a package that has the target. Beside a `lerna.json` with neither, each target's `dependsOn` is its `^` self (`build` → `^build`), in place of any other, as Lerna hands it to Nx; the exported graph holds no such edge. A migration writes the same.
172
+
169
173
  ### Project tags
170
174
 
171
175
  An Nx project's `tags` are its vx `tags`, so `vx run build --filter tag:scope:web` (or Nx's `--projects tag:scope:web`) selects what `nx run-many -t build -p tag:scope:web` does. A package whose `vx.config` declares `tags` keeps its own. A blank tag is dropped (vx refuses one).
@@ -202,10 +206,11 @@ Why the command carries the options: vx's key sees them (resolved-config hashing
202
206
  ## `bunx @vzn/vx-migrate` — write the configs
203
207
 
204
208
  ```bash
205
- bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json
209
+ bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json, then vite-plus
206
210
  bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
207
211
  bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
208
212
  bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
213
+ bunx @vzn/vx-migrate --from vite-task # Vite Task beside turbo.json or Nx
209
214
  bunx @vzn/vx-migrate --native # write vx.config.ts files without asking
210
215
  bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), as `vx init` writes it
211
216
  bunx @vzn/vx-migrate --help # the usage, exit 0
@@ -215,7 +220,7 @@ It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works
215
220
 
216
221
  - In a terminal it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (the workspace file `vx init` writes, declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native.
217
222
  - With no `vx.workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): 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 `vx init` writes, or to one already in that shape; any other workspace file is the user's and left alone.
218
- - It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, plus `@vzn/vx-migrate` for keep (the declared plugins are built into the vx binary, so none is installed) (`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. `--no-install` skips it.
223
+ - It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, the declared plugins' packages, 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. `--no-install` skips it.
219
224
 
220
225
  A Lerna repo is an Nx one: Lerna 6+ runs `lerna run` on Nx's task runner over the graph `nx graph` exports, nx.json or not, so a `lerna.json` with no nx.json and no turbo.json is mapped from that graph, and keep writes the workspace file declaring `nx()` (`vx init` adopts by nx.json, and would map the scripts). Lerna is the installed one, else the root manifest's range; with neither, the lerna.json is another tool's (lerna-lite reads it too, and runs no Nx). A root whose scripts never `lerna run` runs its tasks another way and publishes with Lerna (webdriverio: `run-s`, `pnpm -r`), so its scripts stay the source. Not where Lerna runs its own runner: `useNx: false`, or Lerna 5 without `useNx: true`; those are `vx init`'s scripts mapping. Beside turbo.json, Turbo runs the tasks and Lerna only publishes: turbo.json is the source.
221
226
 
@@ -240,7 +245,25 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
240
245
 
241
246
  ### Nx
242
247
 
243
- Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `vx.workspace.ts` still holds, as the Turbo migration does: the `nx()` `vx init` declared, which keeps reading nx.json, and a lockfile with no `@vzn/vx-lockfile` plugin, where Nx keyed each project on the npm packages it depends on. A written command that still runs Nx itself (a run-commands `nx run b:build`, `npx nx test b`, a script's `nx exec -- tsc`) carries a TODO: it works only while Nx is installed. An Nx project no workspace glob lists (an integrated repo's `project.json` library) gets a `package.json` (`name`, `private`) where it has none, and a note names the directories to add to `workspaces` (or `pnpm-workspace.yaml`): core finds a project only through those globs, and the migrator never edits the root manifest. A target with `configurations` writes one task per configuration (`build`, `build:ci`). A project's Nx `tags` are written as its `tags`. Named inputs expand from `nx.json` when readable. An output path is kept as written, a `!` one too (`{projectRoot}/dist` → `dist`, `{projectRoot}/bin/tool` → `bin/tool`; one naming an unset `{options.x}` is dropped, as Nx drops it; a dotted `{options.outputPath.base}` walks the options, as Nx does; an extglob is put in vx's grammar: Next's inferred `.next/!(cache)/**/*` is `.next/*/**/*` with `!.next/cache/**/*`, `@(js|map)` is `{js,map}`, and a form with no vx spelling is dropped with a TODO): vx reads a bare path as the file or the whole tree under it, so a directory and an extensionless binary both save and restore. nx.json's `parallel`, `defaultBase` and `maxCacheSize`, which `nx()` applies live, are each a field of the `vx.workspace.ts` it writes (`concurrency`, `affectedBase`, `cacheRetention.maxSize`), read from nx.json alone, never the environment; beside a workspace file already there, or for a size vx cannot read (`1.5 GB`), each is a note naming the field to add. vx derives package edges from `package.json`; an Nx graph edge with no manifest path (`implicitDependencies`, a tsconfig path) becomes, for each `^target` of the dependant, an explicit `pkg#target` edge to what Nx's own walk reaches — each dependency that has the target, and through one that lacks it, its dependencies — so the order and the key are Nx's. The reverse too: where a project's manifest names a workspace package its Nx graph does not reach (an `implicitDependencies: ["!a"]` that breaks a manifest cycle), its `^target` becomes the explicit edges Nx draws, since vx's `^` would follow the manifest and bring the cycle back.
248
+ Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does; with no `node_modules/.bin/nx`, as in a fresh clone, it stops and names the repo manager's install), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `vx.workspace.ts` still holds, as the Turbo migration does: the `nx()` `vx init` declared, which keeps reading nx.json, and a lockfile with no `@vzn/vx-lockfile` plugin, where Nx keyed each project on the npm packages it depends on. A written command that still runs Nx itself (a run-commands `nx run b:build`, `npx nx test b`, a script's `nx exec -- tsc`) carries a TODO: it works only while Nx is installed. An Nx project no workspace glob lists (an integrated repo's `project.json` library) gets a `package.json` (`name`, `private`) where it has none, and a note names the directories to add to `workspaces` (or `pnpm-workspace.yaml`): core finds a project only through those globs, and the migrator never edits the root manifest. A target with `configurations` writes one task per configuration (`build`, `build:ci`). A project's Nx `tags` are written as its `tags`. Named inputs expand from `nx.json` when readable. An output path is kept as written, a `!` one too (`{projectRoot}/dist` → `dist`, `{projectRoot}/bin/tool` → `bin/tool`; one naming an unset `{options.x}` is dropped, as Nx drops it; a dotted `{options.outputPath.base}` walks the options, as Nx does; an extglob is put in vx's grammar: Next's inferred `.next/!(cache)/**/*` is `.next/*/**/*` with `!.next/cache/**/*`, `@(js|map)` is `{js,map}`, and a form with no vx spelling is dropped with a TODO): vx reads a bare path as the file or the whole tree under it, so a directory and an extensionless binary both save and restore. nx.json's `parallel`, `defaultBase` and `maxCacheSize`, which `nx()` applies live, are each a field of the `vx.workspace.ts` it writes (`concurrency`, `affectedBase`, `cacheRetention.maxSize`), read from nx.json alone, never the environment; beside a workspace file already there, or for a size vx cannot read (`1.5 GB`), each is a note naming the field to add. vx derives package edges from `package.json`; an Nx graph edge with no manifest path (`implicitDependencies`, a tsconfig path) becomes, for each `^target` of the dependant, an explicit `pkg#target` edge to what Nx's own walk reaches — each dependency that has the target, and through one that lacks it, its dependencies — so the order and the key are Nx's. The reverse too: where a project's manifest names a workspace package its Nx graph does not reach (an `implicitDependencies: ["!a"]` that breaks a manifest cycle), its `^target` becomes the explicit edges Nx draws, since vx's `^` would follow the manifest and bring the cycle back.
249
+
250
+ ### Vite Task
251
+
252
+ 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.
253
+
254
+ | Vite Task | vx |
255
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
256
+ | `command` (a string, or an array run in order) | `exec.command`, the array joined with `&&`; `command: []` is a group task |
257
+ | `cwd` | `cd <cwd> && ` before the command |
258
+ | `dependsOn: 'x'` / `'pkg#x'` | the same |
259
+ | `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 |
260
+ | `cache.input` / `cache.output` globs | `cache.inputs.files` / `cache.outputs.files`; `base: 'workspace'` → `workspaceFiles`, a root task's too |
261
+ | omitted or `{ auto: true }` (traced files) | no `cache` block, with a TODO: vx infers no inputs or outputs |
262
+ | `cache.env` | `cache.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 |
263
+ | `cache.untrackedEnv` | `exec.env.passThrough`, wildcards as `cache.env` |
264
+ | `cache: false`, root `run.cache` `false` / `tasks: false` | no `cache` block |
265
+ | a `package.json` script | a task, uncached; with root `run.cache.scripts` a TODO for its cache |
266
+ | `run.enablePrePostScripts` (default `true`) | a script's `pre<name>` / `post<name>` folded into its command; `false` keeps them tasks of their own |
244
267
 
245
268
  ## `turboCache()` — a Turbo remote cache
246
269
 
@@ -267,16 +290,17 @@ export default defineWorkspace({
267
290
 
268
291
  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. With no token the plugin **declines** and the run stays local.
269
292
 
270
- | Option | Environment variable | Meaning |
271
- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
272
- | `apiUrl` | `TURBO_API` | base URL of the cache server; default with a token: `https://vercel.com/api`; a `user:pass@` in it is refused |
273
- | `token` | `TURBO_TOKEN` | Bearer token on every request |
274
- | `teamId` | `TURBO_TEAMID` | `teamId` query parameter; required with `signatureKey` |
275
- | `teamSlug` | `TURBO_TEAM` | `slug` query parameter |
276
- | `signatureKey` | `TURBO_REMOTE_CACHE_SIGNATURE_KEY`, read only under turbo.json's `remoteCache.signature: true` (or `TURBO_SIGNATURE`), as Turbo reads it | HMAC-SHA256 key (≥ 32 bytes, used raw); a download whose tag does not verify is a miss |
277
- | `timeoutMs` | `TURBO_REMOTE_CACHE_TIMEOUT`, then `remoteCache.timeout` (seconds) | HEAD/GET/POST deadline (default 30 s; 0 none) |
278
- | `uploadTimeoutMs` | `TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT`, then `remoteCache.uploadTimeout` (seconds) | PUT deadline (default 60 s; 0 none) |
279
- | `retries` | — | resends of a request answered 429 / 5xx (not 501) or never connected (default 1, Turbo's); 0 turns them off |
293
+ | Option | Environment variable | Meaning |
294
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
295
+ | `apiUrl` | `TURBO_API` | base URL of the cache server; default with a token: `https://vercel.com/api`; a `user:pass@` in it is refused |
296
+ | `token` | `TURBO_TOKEN` | Bearer token on every request |
297
+ | `teamId` | `TURBO_TEAMID` | `teamId` query parameter; required with `signatureKey` |
298
+ | `teamSlug` | `TURBO_TEAM` | `slug` query parameter |
299
+ | `signatureKey` | `TURBO_REMOTE_CACHE_SIGNATURE_KEY`, read only under turbo.json's `remoteCache.signature: true` (or `TURBO_SIGNATURE`), as Turbo reads it | HMAC-SHA256 key (≥ 32 bytes, used raw); a download whose tag does not verify is a miss |
300
+ | `timeoutMs` | `TURBO_REMOTE_CACHE_TIMEOUT`, then `remoteCache.timeout` (seconds) | HEAD/GET/POST deadline (default 30 s; 0 none) |
301
+ | `uploadTimeoutMs` | `TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT`, then `remoteCache.uploadTimeout` (seconds) | PUT deadline (default 60 s; 0 none) |
302
+ | `retries` | — | resends of a request answered 429 / 5xx (not 501) or never connected (default 1, Turbo's); 0 turns them off |
303
+ | `preflight` | `TURBO_PREFLIGHT` (1 or 0), then `remoteCache.preflight` | send Turbo's `OPTIONS` preflight before each artifact request and go where its `Location` points (relative to `apiUrl`); the token goes along only when `Access-Control-Allow-Headers` admits `Authorization` (default off) |
280
304
 
281
305
  The signature is Turbo's current scheme (`artifact-signature:v2`: prefix, hash, team id and body, each length-prefixed, under HMAC-SHA256, base64 in `x-artifact-tag`). A signed body is written to a temp file before its tag can be checked, so one past core's artifact ceiling (2 GiB, at zstd's bound) is refused as it passes it, and is a miss.
282
306
 
@@ -311,6 +335,7 @@ The Nx spec has no existence probe, so `has` (the `--dry` prediction; the prefet
311
335
  - A remote error degrades to a **miss** and a warning that names the request, the artifact and the server — `vx/turbo-cache: upload 32248a2a7c89e241 to https://cache.example.com/v8/artifacts failed: no answer within 60000 ms` — never the token. The run never fails because of the cache.
312
336
  - The same failure is said once per run: an unreachable server fails the probe, the download and the upload alike, and the run prints the first and, at its end, `vx/turbo-cache: 2 more requests failed the same way: Unable to connect. Is the computer able to access the url?` (core's `LayeredCache` does the counting, for every cache plugin).
313
337
  - A request answered `429` or `5xx` (not `501`), or one that never connected (a refused port, an unresolved host), is sent again after 2 s — a `429` after its `Retry-After`, capped at 10 s — as Turbo's client does. A spent deadline is not: it has already cost its wait.
338
+ - 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 hung server costs three deadlines, not one per task.
314
339
  - 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).
315
340
  - Policy (`--cache=remote:r`, …) is enforced by core's `LayeredCache`, which the plugins wrap — a read-only token pairs naturally with `remote:r`.
316
341
  - Each tool's own switches narrow that policy, never widen it, as they do for the tool: `TURBO_CACHE` (`--cache`'s syntax, an omitted source off) and `TURBO_REMOTE_CACHE_READ_ONLY` for `turboCache()`, `NX_SKIP_REMOTE_CACHE` / `NX_DISABLE_REMOTE_CACHE` (`true`) for `nxCache()`. A CI that keeps untrusted pull requests off the shared cache that way keeps vx off it too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.595",
3
+ "version": "0.0.596",
4
4
  "license": "MIT",
5
5
  "description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "@vzn/vx": "^0.0.595"
42
+ "@vzn/vx": "^0.0.596"
43
43
  },
44
44
  "repository": {
45
45
  "type": "git",
package/src/adopt.ts CHANGED
@@ -37,7 +37,7 @@ const LOCKFILES: ReadonlyArray<readonly [string, PackageManager]> = [
37
37
 
38
38
  function readText(file: string): string {
39
39
  try {
40
- return readFileSync(file, 'utf8')
40
+ return readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
41
41
  } catch {
42
42
  return ''
43
43
  }
@@ -22,6 +22,7 @@ import { relPosix } from './paths.js'
22
22
  import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
23
23
  import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
24
24
  import { adoptedToolNotes } from './workspace-notes.js'
25
+ import { spelledNames } from './spelled-env.js'
25
26
 
26
27
  /** What a task's `npm_package_*` read: the manifest, so a bump reaches them. */
27
28
  const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
@@ -112,36 +113,6 @@ export async function migrateTurbo(
112
113
  }
113
114
  }
114
115
 
115
- /** Source and env-example files a framework build reads its variables from. */
116
- const SPELLS_ENV =
117
- /\.(c|m)?(j|t)sx?$|\.(vue|svelte|astro|html)$|(^|\/)\.env\.(example|sample|template)$/
118
-
119
- /**
120
- * The upper-case names the tracked source under `dirs` spells (`NEXT_PUBLIC_API`
121
- * in `process.env.NEXT_PUBLIC_API` or an `.env.example`), sorted. Without
122
- * git, none: the note still names the framework's prefix.
123
- */
124
- async function spelledNames(
125
- root: string,
126
- dirs: readonly string[],
127
- tracked: readonly string[] | null,
128
- ): Promise<string[]> {
129
- if (tracked === null) return []
130
- const prefixes = dirs.map((dir) => {
131
- const rel = relPosix(root, dir)
132
- return rel === '' || rel === '.' ? '' : `${rel}/`
133
- })
134
- const names = new Set<string>()
135
- for (const f of tracked) {
136
- if (!SPELLS_ENV.test(f) || !prefixes.some((p) => f.startsWith(p))) continue
137
- const text = await Bun.file(path.join(root, f))
138
- .text()
139
- .catch(() => '')
140
- for (const m of text.matchAll(/\b[A-Z][A-Z0-9_]*_[A-Z0-9_]+\b/g)) names.add(m[0])
141
- }
142
- return [...names].sort()
143
- }
144
-
145
116
  /**
146
117
  * The workspace root as a project when turbo.json declares `//#` tasks and
147
118
  * no glob lists the root: the `vx.config.ts` written there is what makes
@@ -0,0 +1,40 @@
1
+ // `bunx @vzn/vx-migrate --from vite-task`: each package's `vite.config`
2
+ // `run` block (vite-plus's `vp run`) and its package.json scripts, as a
3
+ // migration plan.
4
+
5
+ import { type MigrationFormat, type MigrationPlan, type ProjectMeta } from '@vzn/vx'
6
+ import { spelledNames } from './spelled-env.js'
7
+ import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
8
+ import { mapViteTaskWorkspace, viteTaskProjects } from './vite-task/vite-task-map.js'
9
+
10
+ export async function migrateViteTask(
11
+ root: string,
12
+ metas: readonly ProjectMeta[],
13
+ format: MigrationFormat = 'ts',
14
+ ): Promise<MigrationPlan> {
15
+ const withRoot = await viteTaskProjects(root, metas)
16
+ const tracked = await trackedFiles(root)
17
+ const mapped = await mapViteTaskWorkspace(root, withRoot.metas, {
18
+ ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
19
+ ownConfig: () => `vx.config.${format}`,
20
+ sourceNames: (dirs) => spelledNames(root, dirs, tracked),
21
+ })
22
+ // A committed file under an output is taken back, or the first run's
23
+ // clean deletes it (Vite Task never cleans).
24
+ if (tracked !== null)
25
+ for (const [id, todo] of spareTrackedOutputs(root, mapped.projects, tracked)) {
26
+ const at = id.lastIndexOf('#')
27
+ const p = mapped.projects.find((x) => x.name === id.slice(0, at))
28
+ p?.tasks.find((t) => t.name === id.slice(at + 1))?.todos.push(todo)
29
+ }
30
+ return {
31
+ headerNotes: [
32
+ 'each `run.tasks` entry and package.json script became a task; a task whose files ' +
33
+ 'Vite Task traced (no `cache.input` / `cache.output`, or `{ auto: true }`) runs ' +
34
+ 'uncached until its TODO declares them',
35
+ ],
36
+ projects: mapped.projects,
37
+ extraFiles: [],
38
+ notes: [...withRoot.notes, ...mapped.notes],
39
+ }
40
+ }
package/src/migrate.ts CHANGED
@@ -1,11 +1,12 @@
1
- // `vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry]
1
+ // `vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry]
2
2
  // [--force] [--mjs]` — one vx.config.ts per workspace package from an
3
3
  // existing Turbo or Nx setup (`--keep`: the workspace file that reads it
4
4
  // live instead, as `vx init` writes it), and vx installed with the repo's
5
5
  // manager.
6
6
  // Without either mode flag a terminal is asked; anything else is native. Source auto-detect: turbo.json → Turbo;
7
7
  // .nx/workspace-data/project-graph.json, nx.json or a Lerna-on-Nx lerna.json → Nx (the resolved
8
- // graph, exported by nx if absent);
8
+ // graph, exported by nx if absent); `vite-plus` in the root package.json → Vite Task, last:
9
+ // vite-plus is a whole toolchain, so beside turbo.json or Nx it is not the task runner.
9
10
  // The mappers return a plan; core's migration seam
10
11
  // (`applyMigration`) renders, guards, writes and reports, so what this
11
12
  // package writes reads exactly like what `vx init` writes.
@@ -33,9 +34,11 @@ import {
33
34
  missingPackages,
34
35
  MODE_QUESTION,
35
36
  ownVersion,
37
+ packageManagerOf,
36
38
  parseModeAnswer,
37
39
  } from './adopt.js'
38
40
  import { migrateTurbo } from './migrate-turbo.js'
41
+ import { migrateViteTask } from './migrate-vite-task.js'
39
42
  import { turboConfigFile } from './turbo/turbo-map.js'
40
43
  import {
41
44
  extendWorkspaceFile,
@@ -51,7 +54,7 @@ export interface MigrateArgs {
51
54
  force: boolean
52
55
  /** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
53
56
  mjs: boolean
54
- from?: 'turbo' | 'nx'
57
+ from?: 'turbo' | 'nx' | 'vite-task'
55
58
  /** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
56
59
  mode?: AdoptionMode
57
60
  /** `--no-install`: leave package.json's dependencies alone. */
@@ -62,7 +65,7 @@ export interface MigrateArgs {
62
65
  }
63
66
 
64
67
  const USAGE =
65
- 'usage: vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry] [--force] [--mjs]'
68
+ 'usage: vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry] [--force] [--mjs]'
66
69
 
67
70
  export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
68
71
  const out: MigrateArgs = { dry: false, force: false, mjs: false }
@@ -80,10 +83,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
80
83
  out.mode = mode
81
84
  } else if (a === '--from' || a?.startsWith('--from=')) {
82
85
  const v = a === '--from' ? args[++i] : a.slice('--from='.length)
83
- if (v !== 'turbo' && v !== 'nx') {
86
+ if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task') {
84
87
  return {
85
88
  ...out,
86
- error: `--from must be turbo or nx (package.json scripts: \`vx init\`)`,
89
+ error: `--from must be turbo, nx or vite-task (package.json scripts: \`vx init\`)`,
87
90
  }
88
91
  }
89
92
  out.from = v
@@ -152,16 +155,31 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
152
155
  throw new UserError('--from turbo, but no turbo.json at the workspace root')
153
156
  }
154
157
 
155
- const runner: 'turbo' | 'nx' | undefined =
156
- parsed.from ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson || lerna ? 'nx' : undefined)
158
+ const runner: 'turbo' | 'nx' | 'vite-task' | undefined =
159
+ parsed.from ??
160
+ (hasTurbo
161
+ ? 'turbo'
162
+ : hasGraph || hasNxJson || lerna
163
+ ? 'nx'
164
+ : (await hasVitePlus(root))
165
+ ? 'vite-task'
166
+ : undefined)
157
167
  if (runner === undefined) {
158
168
  throw new UserError(
159
- 'nothing to migrate: no turbo.json and no Nx workspace — ' +
169
+ 'nothing to migrate: no turbo.json, no Nx workspace and no vite-plus — ' +
160
170
  'for package.json scripts, run `vx init`',
161
171
  )
162
172
  }
163
- const mode = parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry))
164
- if (mode === 'keep') return keep(root, runner, hasTurbo, lerna, parsed)
173
+ if (runner === 'vite-task' && parsed.mode === 'keep') {
174
+ throw new UserError(
175
+ '--keep: no plugin runs a Vite Task config live; drop --keep to write vx.config files',
176
+ )
177
+ }
178
+ const mode =
179
+ runner === 'vite-task'
180
+ ? 'native'
181
+ : (parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry)))
182
+ if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, hasTurbo, lerna, parsed)
165
183
 
166
184
  const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
167
185
  // No workspace file yet: this run writes one, declaring the plugins the
@@ -169,7 +187,10 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
169
187
  const writesWorkspace = workspaceFileAt(root) === undefined
170
188
  let source: string
171
189
  let plan: MigrationPlan
172
- if (runner === 'nx') {
190
+ if (runner === 'vite-task') {
191
+ source = 'vite.config run.tasks'
192
+ plan = await migrateViteTask(root, metas, format)
193
+ } else if (runner === 'nx') {
173
194
  if (hasGraph) {
174
195
  source = NX_GRAPH_REL
175
196
  plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
@@ -177,6 +198,14 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
177
198
  // Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
178
199
  // when exported. The workspace's own nx exports it, as `nx()` does, into
179
200
  // a temp file; the user ran that step by hand until 2026-10-01.
201
+ // A fresh clone: the export's own reason names the plugin's `graph`
202
+ // option, which the CLI does not take.
203
+ if (!existsSync(path.join(root, 'node_modules', '.bin', 'nx'))) {
204
+ throw new UserError(
205
+ `nx is not installed here (no node_modules/.bin/nx), and vx-migrate reads the graph it exports — ` +
206
+ `run \`${packageManagerOf(root, process.env['npm_config_user_agent'])} install\`, then vx-migrate again`,
207
+ )
208
+ }
180
209
  const tmp = await mkdtemp(path.join(os.tmpdir(), 'vx-migrate-nx-'))
181
210
  try {
182
211
  const snapshot = path.join(tmp, 'project-graph.json')
@@ -230,9 +259,21 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
230
259
  ],
231
260
  }
232
261
  }
262
+ // An executor target is an `nx-exec` line and a `.env` one an `nx-env`
263
+ // line: both bins are this package's, and `bunx` leaves none behind.
264
+ const runsBins = plan.projects.some((p) =>
265
+ p.tasks.some((t) => {
266
+ const cmd = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
267
+ return typeof cmd === 'string' && /^nx-(exec|env) /.test(cmd)
268
+ }),
269
+ )
233
270
  const headerNotes = refused
234
271
  ? []
235
- : await prepareRepo(root, ['@vzn/vx', ...plugins.map((p) => p.pkg)], parsed)
272
+ : await prepareRepo(
273
+ root,
274
+ ['@vzn/vx', ...(runsBins ? ['@vzn/vx-migrate'] : []), ...plugins.map((p) => p.pkg)],
275
+ parsed,
276
+ )
236
277
  return applyMigration({
237
278
  root,
238
279
  metas,
@@ -246,6 +287,17 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
246
287
  })
247
288
  }
248
289
 
290
+ /** vite-plus (`vp run`) in the root package.json's dependencies. */
291
+ async function hasVitePlus(root: string): Promise<boolean> {
292
+ const pkg = (await Bun.file(path.join(root, 'package.json'))
293
+ .json()
294
+ .catch(() => ({}))) as Record<string, unknown>
295
+ return ['dependencies', 'devDependencies'].some((f) => {
296
+ const deps = pkg[f]
297
+ return typeof deps === 'object' && deps !== null && Object.hasOwn(deps, 'vite-plus')
298
+ })
299
+ }
300
+
249
301
  /** A terminal is asked which adoption it wants; anything else gets native. */
250
302
  async function askMode(
251
303
  runner: 'turbo' | 'nx',
@@ -392,7 +444,10 @@ const LERNA_RUN = /(?:^|[\s;&|(])lerna\s+run\s/
392
444
  function lernaOnNx(root: string): boolean {
393
445
  const json = (file: string): Record<string, unknown> | undefined => {
394
446
  try {
395
- return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
447
+ return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) as Record<
448
+ string,
449
+ unknown
450
+ >
396
451
  } catch {
397
452
  return undefined
398
453
  }
package/src/nx/index.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  readNxJson,
43
43
  } from './nx-map.js'
44
44
  import { exportGraph } from './export-graph.js'
45
+ import { lernaJsonText } from './lerna.js'
45
46
  export type {
46
47
  NxExecutors,
47
48
  NxExecutorTarget,
@@ -295,7 +296,7 @@ const textOf = (file: string): Promise<string> =>
295
296
  .catch(() => '\0absent')
296
297
 
297
298
  /**
298
- * Everything the mapping reads: the graph, nx.json and its `extends` chain, every package manifest
299
+ * Everything the mapping reads: the graph, nx.json and its `extends` chain, lerna.json, every package manifest
299
300
  * and the package.json of each graph node no package matches (its
300
301
  * synthetic project's name), the `.env` names in every project dir,
301
302
  * NX_LOAD_DOT_ENV_FILES, whether the bins the tasks run are installed,
@@ -326,6 +327,8 @@ async function nxReads(
326
327
  return [
327
328
  graphText,
328
329
  ...(await Promise.all(chain.map(textOf))),
330
+ // Its presence orders each target after its dependencies' (`lerna run`).
331
+ String(await lernaJsonText(root)),
329
332
  // A config file added beside mapped tasks changes what an output may cover.
330
333
  JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
331
334
  ...(await Promise.all(unmatched.map((r) => textOf(path.join(root, r, 'package.json'))))),
@@ -0,0 +1,60 @@
1
+ // `lerna run <x>` orders each package's <x> after its dependencies' <x>.
2
+ // Lerna 6+ runs it on Nx's task runner and passes that order as the target
3
+ // dependency `^<x>` — and drops every other one — unless the repo
4
+ // configures Nx tasks itself: nx.json `targetDefaults` (or the legacy
5
+ // `targetDependencies`), or an `nx` key in the package.json of a package
6
+ // that has <x> (lerna's `prepNxOptions`, 9.0.7). The exported graph holds
7
+ // none of this, so a Lerna repo mapped from it ran every build at once.
8
+
9
+ import path from 'node:path'
10
+
11
+ interface Target {
12
+ dependsOn?: unknown[]
13
+ }
14
+ interface Node {
15
+ data?: { root?: string; targets?: Record<string, Target> }
16
+ }
17
+
18
+ /** The text of `root/lerna.json`, or null when there is none. */
19
+ export async function lernaJsonText(root: string): Promise<string | null> {
20
+ return Bun.file(path.join(root, 'lerna.json'))
21
+ .text()
22
+ .catch(() => null)
23
+ }
24
+
25
+ /**
26
+ * The graph's nodes with Lerna's order applied, or `nodes` itself when the
27
+ * repo has no lerna.json or configures Nx's task dependencies.
28
+ * `manifest(rel)`: the package.json a root-relative node root holds.
29
+ */
30
+ export async function withLernaOrder<N extends Node>(
31
+ root: string,
32
+ nodes: Record<string, N>,
33
+ nxJson: Record<string, unknown> | undefined,
34
+ manifest: (rel: string) => Promise<Record<string, unknown> | undefined>,
35
+ ): Promise<Record<string, N>> {
36
+ if ((await lernaJsonText(root)) === null) return nodes
37
+ const keys = (v: unknown): number =>
38
+ v !== null && typeof v === 'object' ? Object.keys(v).length : 0
39
+ if (keys(nxJson?.['targetDependencies'] || nxJson?.['targetDefaults']) > 0) return nodes
40
+ const configured = new Set<string>()
41
+ for (const node of Object.values(nodes)) {
42
+ const targets = node.data?.targets
43
+ if (targets === undefined) continue
44
+ if ((await manifest(node.data?.root ?? '.'))?.['nx'] === undefined) continue
45
+ for (const name of Object.keys(targets)) configured.add(name)
46
+ }
47
+ const out: Record<string, N> = {}
48
+ for (const [id, node] of Object.entries(nodes)) {
49
+ const targets = node.data?.targets
50
+ if (targets === undefined) {
51
+ out[id] = node
52
+ continue
53
+ }
54
+ const ordered: Record<string, Target> = {}
55
+ for (const [name, t] of Object.entries(targets))
56
+ ordered[name] = configured.has(name) ? t : { ...t, dependsOn: [`^${name}`] }
57
+ out[id] = { ...node, data: { ...node.data, targets: ordered } }
58
+ }
59
+ return out
60
+ }