@vzn/vx-migrate 0.0.591 → 0.0.595
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 +10 -7
- package/package.json +2 -2
- package/src/adoption-plugin.ts +2 -1
- package/src/glob-grammar.ts +20 -6
- package/src/migrate-nx.ts +37 -17
- package/src/migrate.ts +115 -15
- package/src/nx/nx-deps.ts +1 -1
- package/src/nx/nx-inputs.ts +3 -7
- package/src/nx/nx-map.ts +18 -2
- package/src/nx-command.ts +36 -4
- package/src/nx-env.cjs +4 -1
- package/src/nx-exec.cjs +4 -1
- package/src/paths.ts +5 -3
- package/src/tracked-outputs.ts +36 -2
- package/src/turbo/turbo-map.ts +109 -21
- package/src/turbo-cache/index.ts +18 -2
- package/src/workspace-notes.ts +1 -1
- package/src/workspace-plugins.ts +2 -0
package/README.md
CHANGED
|
@@ -55,16 +55,16 @@ Rules:
|
|
|
55
55
|
- **Definition order**: root `pkg#name` if there is one, else root `name` (the first replaces the second whole, as Turbo looks it up), then each package config its own `turbo.json` extends (`"extends": ["//", "shared"]`, read by Turbo 2.11, refused by 2.5; nearest last, a parent missing or a cycle refused as Turbo refuses it) and its own, each winning field by field, except an overlay array holding `$TURBO_EXTENDS$` (Turbo 2.5+), which is the inherited list plus the overlay's other entries (`inputs: ["$TURBO_EXTENDS$", "config.json"]`).
|
|
56
56
|
- **The command is the script body** with its `pre<name>` / `post<name>` hooks folded in, in that order, through core's `foldScriptHooks`: each part in its own subshell, the chain stopping at the first that fails, forwarded `--` args reaching the body alone (npm and pnpm run them around `<name>` without being asked; novu's `prebuild` copies the CSS its `build` inlines), one process less per task than `pnpm run <name>` — except a body that calls yarn's `run` builtin (`run -T rollup -c`, `run clean && run build`; yarn ≥ 2 runs scripts in its own shell), which is `run: command not found` in sh: it runs as `yarn run <name>`, as Turbo and Nx run it, and yarn ≥ 2 runs no hooks, so none are folded there. A Yarn Plug'n'Play workspace (`.yarnrc.yml` with no `nodeLinker`, or `pnp`) has no `node_modules`: a dependency resolves only through `.pnp.cjs` and a bin only through yarn's shims, so there every script runs as `yarn run <name>`, and a `nodeLinker` change maps afresh. Same rules for `nx:run-script` below (strapi and novu, 2026-09-11).
|
|
57
57
|
- **Turbo 2.11's task `command`** (`futureFlags.experimentalTaskCommand`) wins over the script, as Turbo holds it: an argv is the command — each word quoted, run from the package dir, no `pre`/`post` hooks — and gives the package the task even with no script (turborepo's `@turbo/types#build`, `docs#schema`); `null` or `[]` is Turbo's no-op node, no task even where the script exists, its edges passed through; a per-toolchain map applies its `javascript` entry (or `typescript`, Turbo's alias), and without one the script runs. A shape Turbo would refuse keeps the script, with a todo.
|
|
58
|
-
- `dependsOn`: `^x` passes through when some package runs `x
|
|
58
|
+
- `dependsOn`: `^x` passes through when some package runs `x` or turbo.json defines it, and is dropped otherwise (core refuses a `^x` no project declares as a typo); `pkg#task` is kept only when `pkg` emits `task` (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo's no-op node, so its own edges pass through in its place (`test → codegen → ^build` with no `codegen` script is `test → ^build`); another package's `^x` or `pkg#x` reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind's `ui` has no `build` script; its `build` builds `build:styles` and `build:components`, and `web#build` waits on them), and so does one whose `^` edge names another task (rallly's script-less `build` holds `^db:generate`), while one whose only edge is `^` to its own name needs none, since core's `^x` walks past a package without `x`; a no-op node nothing else reaches runs nothing, its own edges included, where Turbo still runs them (kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script; `vx run test --all` does not, and `vx run build test --all` does both); a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. A task turbo.json defines and no package has a script for (documenso's `lint`, shadcn-ui's `check`) is a group task in each package that defines it, its edges kept (a `pkg#task` edge only in `pkg`), so `vx run lint` runs Turbo's no-op nodes and exits 0 as `turbo run lint` does. Root `//#task`s run in the workspace root, as under Turbo: `turbo()` names the root a project through core's `discover` stage when turbo.json declares one (no `vx.config` needed; a root no member glob lists), and `//#x` in `dependsOn` is an edge to it, named by the root `package.json` `name`; a root task's globs are the workspace's (`workspaceFiles`), since Turbo hashes a root task over the whole repo; with no root project (a root `package.json` with no `name`, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does `turbo()`; in a single-package repo (no workspaces, Turbo's `non-monorepo` example) turbo.json's plain tasks run on the root package, as under Turbo.
|
|
59
59
|
- 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: it runs `true`, cached and 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` running `true`).
|
|
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
|
-
- `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none (vx folds each dependency's key). 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.
|
|
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
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.
|
|
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.
|
|
67
|
-
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` 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; 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. Same for Nx targets.
|
|
67
|
+
- **A task whose inputs read another task's outputs** (a `test` keyed on `**/*` beside a `build` writing `dist/**`): vx's default `rules.upfrontKeys` 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; 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. 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.
|
|
68
68
|
- **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.
|
|
69
69
|
- `cache: false` or `persistent: true` → no `cache` block; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
|
|
70
70
|
- A task's `description` (Turbo 2.11.5's schema) is the vx task's `description`.
|
|
@@ -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:
|
|
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`.
|
|
92
92
|
|
|
93
93
|
| Option | Meaning |
|
|
94
94
|
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
@@ -105,6 +105,7 @@ Nx's own option handling, rendered as one POSIX `sh` line (`vx show` prints it):
|
|
|
105
105
|
- **An argument replaces its option:** an option the arguments after `vx run … --` name (`--region=us`, `--region us`, `--no-region`, `-r` for a one-letter option; no camel-case expansion) is not appended, as Nx drops it; the line decides once it has them. `tests/nx-run-commands-override-live.test.ts` holds it to `nx run`.
|
|
106
106
|
- **Environment:** `env` is `exec.env.define` (so it is in the key), and `color: true` sets `FORCE_COLOR=true`, as Nx does (nx#20465). `envFile` is loaded after the task's `.env` files (below), a name they already set winning, as in Nx (nx#23581).
|
|
107
107
|
- **`readyWhen`** makes the task persistent with that string as its `readyWhen`, so dependents start once it is printed; several strings, all of which Nx waits for, run the line under `nx-env --ready-when` (one flag per string), which passes the output through and prints `nx-env: ready` once every string has appeared on stdout or stderr; that line is the task's `readyWhen`.
|
|
108
|
+
- **A leading Nx call** (`nx <target> <project>`, `nx run <project>:<target>`, through `npx` / `pnpm` / `yarn` / `bunx` or not) in a serial line, or as its one command, is a `dependsOn` edge to that target (its default configuration, as Nx runs it) and leaves the line: ngrx's `build` opens with `nx build-package <project>`. One Nx would hand options or arguments to, one after another command, and one in a parallel line stay commands.
|
|
108
109
|
- **Reported, not reproduced:** per-command `prefix` / `color` decoration, `streamOutput: false`. Decoration in a serial run (`parallel: false`), which Nx refuses, is the failing placeholder with Nx's reason, as `readyWhen` there is. Display-only options (`usePty`, `tty`, `verbose`) change nothing here: vx runs every task without a pseudo-terminal.
|
|
109
110
|
|
|
110
111
|
`tests/nx-exec-live.test.ts` holds each of these shapes to Nx's own run-commands executor on the same options and arguments.
|
|
@@ -201,7 +202,7 @@ Why the command carries the options: vx's key sees them (resolved-config hashing
|
|
|
201
202
|
## `bunx @vzn/vx-migrate` — write the configs
|
|
202
203
|
|
|
203
204
|
```bash
|
|
204
|
-
bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json or
|
|
205
|
+
bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/project-graph.json, nx.json or lerna.json
|
|
205
206
|
bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
|
|
206
207
|
bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
|
|
207
208
|
bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
|
|
@@ -216,6 +217,8 @@ It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works
|
|
|
216
217
|
- 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.
|
|
217
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.
|
|
218
219
|
|
|
220
|
+
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
|
+
|
|
219
222
|
Your `package.json` scripts are never edited. `--dry` installs nothing and says what it would install.
|
|
220
223
|
|
|
221
224
|
`--dry` prints the files instead of writing them; `--force` overwrites existing ones; `--mjs` writes `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts` — the same objects with no type import and no `satisfies`, for a package whose own `tsconfig` includes every `.ts` under it and would compile the config into its dist (TanStack/query, 2026-09-11).
|
|
@@ -237,7 +240,7 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
|
|
|
237
240
|
|
|
238
241
|
### Nx
|
|
239
242
|
|
|
240
|
-
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
|
|
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.
|
|
241
244
|
|
|
242
245
|
## `turboCache()` — a Turbo remote cache
|
|
243
246
|
|
|
@@ -308,7 +311,7 @@ The Nx spec has no existence probe, so `has` (the `--dry` prediction; the prefet
|
|
|
308
311
|
- 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.
|
|
309
312
|
- 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).
|
|
310
313
|
- 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.
|
|
311
|
-
- A refused token (`401`/`403`) warns **once** and turns the layer off for the rest of the process (
|
|
314
|
+
- 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).
|
|
312
315
|
- Policy (`--cache=remote:r`, …) is enforced by core's `LayeredCache`, which the plugins wrap — a read-only token pairs naturally with `remote:r`.
|
|
313
316
|
- 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.
|
|
314
317
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.595",
|
|
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.
|
|
42
|
+
"@vzn/vx": "^0.0.595"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/adoption-plugin.ts
CHANGED
|
@@ -111,7 +111,8 @@ export function adoptionPlugin(
|
|
|
111
111
|
// only reads a stage-given task after validating it. The copy was
|
|
112
112
|
// the per-process memo's guard and cost 10 ms per run at 1,000
|
|
113
113
|
// projects, 3,000 clones (item 609).
|
|
114
|
-
config.tasks
|
|
114
|
+
if (!Object.hasOwn(config.tasks, t.name))
|
|
115
|
+
config.tasks[t.name] = t.task as unknown as TaskConfig
|
|
115
116
|
}
|
|
116
117
|
},
|
|
117
118
|
// The root files the mapping reads (`turbo.json`, `nx.json`) shape every
|
package/src/glob-grammar.ts
CHANGED
|
@@ -63,17 +63,21 @@ function classMembers(body: string): string[] | null {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* `list` without a literal that one of its `!` entries takes back
|
|
67
|
-
* Nx and vx all subtract an exclusion wherever it
|
|
68
|
-
* never an input there either; vx refuses it at
|
|
69
|
-
* unchanged repo's run, so the adapters drop it.
|
|
66
|
+
* `list` without a literal that one of its `!` entries takes back, and
|
|
67
|
+
* without a repeat. Turbo, Nx and vx all subtract an exclusion wherever it
|
|
68
|
+
* sits, so such a file was never an input there either; vx refuses it at
|
|
69
|
+
* load, which would stop an unchanged repo's run, so the adapters drop it.
|
|
70
|
+
* A repeat adds nothing in any order: TanStack Query's `test:eslint` named
|
|
71
|
+
* `eslint.config.js` twice (`sharedGlobals` and its own), and the written
|
|
72
|
+
* config listed it twice.
|
|
70
73
|
*/
|
|
71
|
-
export function withoutTakenBack<T>(
|
|
74
|
+
export function withoutTakenBack<T>(entries: readonly T[]): T[] {
|
|
75
|
+
const list = [...new Set(entries)]
|
|
72
76
|
const wild = /[*?{}]/
|
|
73
77
|
const negative = list.flatMap((e) =>
|
|
74
78
|
typeof e === 'string' && e.startsWith('!') ? [e.slice(1)] : [],
|
|
75
79
|
)
|
|
76
|
-
if (negative.length === 0) return
|
|
80
|
+
if (negative.length === 0) return list
|
|
77
81
|
const globs = negative
|
|
78
82
|
.flatMap((n) => (wild.test(n) ? [n] : [n, `${n.replace(/\/+$/, '')}/**`]))
|
|
79
83
|
.map((g) => new Bun.Glob(g.replace(/^\.\//, '')))
|
|
@@ -83,3 +87,13 @@ export function withoutTakenBack<T>(list: readonly T[]): T[] {
|
|
|
83
87
|
return !globs.some((g) => g.match(lit))
|
|
84
88
|
})
|
|
85
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A path from the file system as a task glob that names only itself: a dir
|
|
93
|
+
* named `my{app}` read as a brace matched `myapp` instead. `[` `]` are
|
|
94
|
+
* literal in a task glob already; `*` and `?` stay, since the schema reads
|
|
95
|
+
* `\*` as a Windows separator, and they match the name too (a superset).
|
|
96
|
+
*/
|
|
97
|
+
export function literalGlob(p: string): string {
|
|
98
|
+
return p.replace(/^!|[{}]/g, '\\$&')
|
|
99
|
+
}
|
package/src/migrate-nx.ts
CHANGED
|
@@ -19,6 +19,8 @@ export async function migrateNx(
|
|
|
19
19
|
metas: readonly ProjectMeta[],
|
|
20
20
|
format: MigrationFormat = 'ts',
|
|
21
21
|
snapshot = path.join(root, NX_GRAPH_REL),
|
|
22
|
+
/** This run writes `vx.workspace.ts`, with nx.json's run settings as fields. */
|
|
23
|
+
writesWorkspace = false,
|
|
22
24
|
): Promise<MigrationPlan> {
|
|
23
25
|
const graph = parseNxGraph(await Bun.file(snapshot).text(), path.relative(root, snapshot))
|
|
24
26
|
const tracked = await trackedFiles(root)
|
|
@@ -64,7 +66,9 @@ export async function migrateNx(
|
|
|
64
66
|
notes: [
|
|
65
67
|
...mapped.notes,
|
|
66
68
|
...unlisted.notes,
|
|
67
|
-
...
|
|
69
|
+
...nxWorkspaceFields((await readNxJson(root).catch(() => null))?.json)
|
|
70
|
+
.filter((f) => !writesWorkspace || f.source === undefined)
|
|
71
|
+
.map((f) => f.note),
|
|
68
72
|
],
|
|
69
73
|
}
|
|
70
74
|
}
|
|
@@ -117,14 +121,24 @@ async function unlistedProjects(
|
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/** A `vx.workspace.ts` field nx.json's run settings call for. */
|
|
125
|
+
export interface NxWorkspaceField {
|
|
126
|
+
readonly field: 'concurrency' | 'affectedBase' | 'cacheRetention'
|
|
127
|
+
/** The value as TypeScript source; undefined when vx cannot read it. */
|
|
128
|
+
readonly source: string | undefined
|
|
129
|
+
/** What to tell a user whose workspace file this run does not write. */
|
|
130
|
+
readonly note: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const quoted = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
134
|
+
|
|
120
135
|
/**
|
|
121
|
-
* nx.json's run-wide settings `nx()` applies live
|
|
122
|
-
* `vx.workspace.ts
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* for every machine, so `NX_PARALLEL` and friends are not read.
|
|
136
|
+
* nx.json's run-wide settings `nx()` applies live: each a field of the
|
|
137
|
+
* written `vx.workspace.ts`, or a migrated repo that ran one task at a time
|
|
138
|
+
* for a shared database ran on every core. nx.json only: the files are
|
|
139
|
+
* written for every machine, so `NX_PARALLEL` and friends are not read.
|
|
126
140
|
*/
|
|
127
|
-
function
|
|
141
|
+
export function nxWorkspaceFields(json: Record<string, unknown> | undefined): NxWorkspaceField[] {
|
|
128
142
|
const nx = json as
|
|
129
143
|
| {
|
|
130
144
|
parallel?: unknown
|
|
@@ -134,21 +148,27 @@ function workspaceNotes(json: Record<string, unknown> | undefined): string[] {
|
|
|
134
148
|
maxCacheSize?: unknown
|
|
135
149
|
}
|
|
136
150
|
| undefined
|
|
137
|
-
const out:
|
|
151
|
+
const out: NxWorkspaceField[] = []
|
|
138
152
|
const parallel = nx?.parallel ?? nx?.tasksRunnerOptions?.default?.options?.parallel
|
|
139
153
|
if (typeof parallel === 'number' && Number.isInteger(parallel) && parallel > 0)
|
|
140
|
-
out.push(
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
out.push({
|
|
155
|
+
field: 'concurrency',
|
|
156
|
+
source: String(parallel),
|
|
157
|
+
note: `nx.json \`parallel: ${parallel}\`: add \`concurrency: ${parallel}\` to vx.workspace.ts`,
|
|
158
|
+
})
|
|
143
159
|
const base = nx?.defaultBase ?? nx?.affected?.defaultBase
|
|
144
160
|
if (typeof base === 'string' && base.trim() !== '')
|
|
145
|
-
out.push(
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
out.push({
|
|
162
|
+
field: 'affectedBase',
|
|
163
|
+
source: quoted(base.trim()),
|
|
164
|
+
note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`affectedBase: ${JSON.stringify(base.trim())}\` to vx.workspace.ts`,
|
|
165
|
+
})
|
|
148
166
|
const size = nxSizeText(nx?.maxCacheSize)
|
|
149
167
|
if (size !== undefined)
|
|
150
|
-
out.push(
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
out.push({
|
|
169
|
+
field: 'cacheRetention',
|
|
170
|
+
source: /^\d+[KMG]?B$/.test(size) ? `{ maxSize: ${quoted(size)} }` : undefined,
|
|
171
|
+
note: `nx.json \`maxCacheSize\`: add \`cacheRetention: { maxSize: ${JSON.stringify(size)} }\` to vx.workspace.ts`,
|
|
172
|
+
})
|
|
153
173
|
return out
|
|
154
174
|
}
|
package/src/migrate.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
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
|
-
// .nx/workspace-data/project-graph.json or
|
|
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
9
|
// The mappers return a plan; core's migration seam
|
|
9
10
|
// (`applyMigration`) renders, guards, writes and reports, so what this
|
|
10
11
|
// package writes reads exactly like what `vx init` writes.
|
|
@@ -23,8 +24,9 @@ import {
|
|
|
23
24
|
type MigrationPlan,
|
|
24
25
|
UserError,
|
|
25
26
|
} from '@vzn/vx'
|
|
26
|
-
import { migrateNx, NX_GRAPH_REL } from './migrate-nx.js'
|
|
27
|
+
import { migrateNx, NX_GRAPH_REL, nxWorkspaceFields } from './migrate-nx.js'
|
|
27
28
|
import { exportGraph } from './nx/export-graph.js'
|
|
29
|
+
import { readNxJson } from './nx/nx-map.js'
|
|
28
30
|
import {
|
|
29
31
|
type AdoptionMode,
|
|
30
32
|
install,
|
|
@@ -41,6 +43,7 @@ import {
|
|
|
41
43
|
renderWorkspaceFile,
|
|
42
44
|
workspaceFileAt,
|
|
43
45
|
workspacePlugins,
|
|
46
|
+
type WorkspacePlugin,
|
|
44
47
|
} from './workspace-plugins.js'
|
|
45
48
|
|
|
46
49
|
export interface MigrateArgs {
|
|
@@ -124,13 +127,15 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
124
127
|
const hasTurbo = turboFile !== null
|
|
125
128
|
const hasGraph = await Bun.file(path.join(root, NX_GRAPH_REL)).exists()
|
|
126
129
|
const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
|
|
130
|
+
// A Turbo repo's lerna.json publishes; Turbo runs its tasks.
|
|
131
|
+
const lerna = !hasTurbo && !hasGraph && !hasNxJson && lernaOnNx(root)
|
|
127
132
|
|
|
128
133
|
// Evaluating teams routinely have two runners checked in — never
|
|
129
134
|
// ask anyone to delete anything; --from disambiguates.
|
|
130
135
|
const found = (
|
|
131
136
|
[
|
|
132
137
|
['turbo', hasTurbo, 'turbo.json'],
|
|
133
|
-
['nx', hasGraph || hasNxJson, 'an nx workspace'],
|
|
138
|
+
['nx', hasGraph || hasNxJson || lerna, 'an nx workspace'],
|
|
134
139
|
] as const
|
|
135
140
|
).filter(([, present]) => present)
|
|
136
141
|
if (parsed.from === undefined && found.length > 1) {
|
|
@@ -148,23 +153,26 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
const runner: 'turbo' | 'nx' | undefined =
|
|
151
|
-
parsed.from ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson ? 'nx' : undefined)
|
|
156
|
+
parsed.from ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson || lerna ? 'nx' : undefined)
|
|
152
157
|
if (runner === undefined) {
|
|
153
158
|
throw new UserError(
|
|
154
159
|
'nothing to migrate: no turbo.json and no Nx workspace — ' +
|
|
155
160
|
'for package.json scripts, run `vx init`',
|
|
156
161
|
)
|
|
157
162
|
}
|
|
158
|
-
const mode = parsed.mode ?? (await askMode(runner, parsed.dry))
|
|
159
|
-
if (mode === 'keep') return keep(root, runner, hasTurbo, parsed)
|
|
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)
|
|
160
165
|
|
|
161
166
|
const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
|
|
167
|
+
// No workspace file yet: this run writes one, declaring the plugins the
|
|
168
|
+
// repo calls for and the run settings nx.json holds.
|
|
169
|
+
const writesWorkspace = workspaceFileAt(root) === undefined
|
|
162
170
|
let source: string
|
|
163
171
|
let plan: MigrationPlan
|
|
164
172
|
if (runner === 'nx') {
|
|
165
173
|
if (hasGraph) {
|
|
166
174
|
source = NX_GRAPH_REL
|
|
167
|
-
plan = await migrateNx(root, metas, format)
|
|
175
|
+
plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
|
|
168
176
|
} else {
|
|
169
177
|
// Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
|
|
170
178
|
// when exported. The workspace's own nx exports it, as `nx()` does, into
|
|
@@ -180,7 +188,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
180
188
|
)
|
|
181
189
|
}
|
|
182
190
|
source = 'nx graph'
|
|
183
|
-
plan = await migrateNx(root, metas, format, snapshot)
|
|
191
|
+
plan = await migrateNx(root, metas, format, snapshot, writesWorkspace)
|
|
184
192
|
} finally {
|
|
185
193
|
await rm(tmp, { recursive: true, force: true })
|
|
186
194
|
}
|
|
@@ -201,16 +209,24 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
201
209
|
existsSync(path.join(p.dir, configName))),
|
|
202
210
|
) ||
|
|
203
211
|
plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
// Drop the note that told the user to declare the lockfile plugin.
|
|
213
|
+
const plugins = writesWorkspace ? workspacePlugins(root) : []
|
|
214
|
+
if (writesWorkspace) {
|
|
215
|
+
const fields =
|
|
216
|
+
runner === 'nx'
|
|
217
|
+
? nxWorkspaceFields((await readNxJson(root).catch(() => null))?.json).flatMap((f) =>
|
|
218
|
+
f.source === undefined ? [] : [{ field: f.field, source: f.source }],
|
|
219
|
+
)
|
|
220
|
+
: []
|
|
208
221
|
plan = {
|
|
209
222
|
...plan,
|
|
210
223
|
headerNotes: plan.headerNotes.filter((n) => !n.includes('from @vzn/vx-lockfile')),
|
|
211
224
|
extraFiles: [
|
|
212
225
|
...plan.extraFiles,
|
|
213
|
-
{
|
|
226
|
+
{
|
|
227
|
+
relPath: `vx.workspace.${format}`,
|
|
228
|
+
contents: renderWorkspaceFile(plugins, format, fields),
|
|
229
|
+
},
|
|
214
230
|
],
|
|
215
231
|
}
|
|
216
232
|
}
|
|
@@ -231,9 +247,13 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
/** A terminal is asked which adoption it wants; anything else gets native. */
|
|
234
|
-
async function askMode(
|
|
250
|
+
async function askMode(
|
|
251
|
+
runner: 'turbo' | 'nx',
|
|
252
|
+
file: string | undefined,
|
|
253
|
+
dry: boolean,
|
|
254
|
+
): Promise<AdoptionMode> {
|
|
235
255
|
if (dry || !process.stdin.isTTY || !process.stdout.isTTY) return 'native'
|
|
236
|
-
const source = runner === 'turbo' ? 'turbo.json' : 'nx.json'
|
|
256
|
+
const source = file ?? (runner === 'turbo' ? 'turbo.json' : 'nx.json')
|
|
237
257
|
for (;;) {
|
|
238
258
|
const mode = parseModeAnswer(prompt(MODE_QUESTION(runner, source)))
|
|
239
259
|
if (mode !== undefined) return mode
|
|
@@ -268,6 +288,7 @@ async function keep(
|
|
|
268
288
|
root: string,
|
|
269
289
|
runner: 'turbo' | 'nx',
|
|
270
290
|
hasTurbo: boolean,
|
|
291
|
+
lerna: boolean,
|
|
271
292
|
args: MigrateArgs,
|
|
272
293
|
): Promise<number> {
|
|
273
294
|
// `vx init` reads turbo.json first.
|
|
@@ -291,6 +312,9 @@ async function keep(
|
|
|
291
312
|
)) {
|
|
292
313
|
process.stdout.write(`vx-migrate: ${note}\n`)
|
|
293
314
|
}
|
|
315
|
+
// `vx init` adopts nx() by nx.json, and a Lerna repo may have none: it
|
|
316
|
+
// would map the scripts instead, so the file is written here.
|
|
317
|
+
if (lerna) return keepLerna(root, plugins, args)
|
|
294
318
|
// A resolve that misses from a directory with no node_modules is an
|
|
295
319
|
// auto-install under Bun: ask the root only where it has the package.
|
|
296
320
|
const installed = path.join(root, 'node_modules', '@vzn', 'vx')
|
|
@@ -316,3 +340,79 @@ async function keep(
|
|
|
316
340
|
}
|
|
317
341
|
return code
|
|
318
342
|
}
|
|
343
|
+
|
|
344
|
+
/** `--keep` in a Lerna repo with no nx.json: the workspace file declaring `nx()`. */
|
|
345
|
+
async function keepLerna(
|
|
346
|
+
root: string,
|
|
347
|
+
plugins: readonly WorkspacePlugin[],
|
|
348
|
+
args: MigrateArgs,
|
|
349
|
+
): Promise<number> {
|
|
350
|
+
const format = args.mjs ? 'mjs' : 'ts'
|
|
351
|
+
const name = `vx.workspace.${format}`
|
|
352
|
+
const existing = workspaceFileAt(root)
|
|
353
|
+
const all = [{ pkg: '@vzn/vx-migrate', factory: 'nx' }, ...plugins]
|
|
354
|
+
if (existing !== undefined && !args.force) {
|
|
355
|
+
const text = readFileSync(path.join(root, existing), 'utf8')
|
|
356
|
+
const extended = extendWorkspaceFile(text, all)
|
|
357
|
+
if (extended === null) {
|
|
358
|
+
throw new UserError(
|
|
359
|
+
`${existing} exists; add nx() from @vzn/vx-migrate to its plugins, or --force replaces it`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
if (!args.dry && extended !== text) await Bun.write(path.join(root, existing), extended)
|
|
363
|
+
const names = undeclared(text, all).map((p) => `${p.factory}()`)
|
|
364
|
+
if (names.length > 0)
|
|
365
|
+
process.stdout.write(
|
|
366
|
+
`vx-migrate: ${args.dry ? 'would declare' : 'declared'} ${names.join(', ')} in ${existing}\n`,
|
|
367
|
+
)
|
|
368
|
+
return 0
|
|
369
|
+
}
|
|
370
|
+
const text = renderWorkspaceFile(all, format)
|
|
371
|
+
if (args.dry) {
|
|
372
|
+
process.stdout.write(`── ${name} ──\n${text}\nvx-migrate: would write ${name} (dry run)\n`)
|
|
373
|
+
return 0
|
|
374
|
+
}
|
|
375
|
+
if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
|
|
376
|
+
await Bun.write(path.join(root, name), text)
|
|
377
|
+
process.stdout.write(`vx-migrate: lerna.json found — wrote ${name} declaring nx()\n`)
|
|
378
|
+
return 0
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Lerna 6+ runs `lerna run` on Nx's task runner over the graph `nx graph`
|
|
383
|
+
* exports, nx.json or not, unless lerna.json says `useNx: false`; Lerna 5
|
|
384
|
+
* ran its own unless it said `useNx: true`. Lerna is the installed one,
|
|
385
|
+
* else the root manifest's range; with neither, the lerna.json is another
|
|
386
|
+
* tool's (lerna-lite reads it too, and runs no Nx). A repo whose root
|
|
387
|
+
* scripts never `lerna run` runs its tasks some other way and publishes
|
|
388
|
+
* with Lerna (webdriverio: `run-s`, `pnpm -r`): its scripts are the source.
|
|
389
|
+
*/
|
|
390
|
+
const LERNA_RUN = /(?:^|[\s;&|(])lerna\s+run\s/
|
|
391
|
+
|
|
392
|
+
function lernaOnNx(root: string): boolean {
|
|
393
|
+
const json = (file: string): Record<string, unknown> | undefined => {
|
|
394
|
+
try {
|
|
395
|
+
return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
396
|
+
} catch {
|
|
397
|
+
return undefined
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const config = json(path.join(root, 'lerna.json'))
|
|
401
|
+
if (config === undefined) return false
|
|
402
|
+
const installed = json(path.join(root, 'node_modules', 'lerna', 'package.json'))?.['version']
|
|
403
|
+
const manifest = json(path.join(root, 'package.json'))
|
|
404
|
+
const declared = ['devDependencies', 'dependencies']
|
|
405
|
+
.map((k) => (manifest?.[k] as Record<string, unknown> | undefined)?.['lerna'])
|
|
406
|
+
.find((v): v is string => typeof v === 'string')
|
|
407
|
+
const version = typeof installed === 'string' ? installed : declared
|
|
408
|
+
if (version === undefined) return false
|
|
409
|
+
const scripts = manifest?.['scripts']
|
|
410
|
+
const runs =
|
|
411
|
+
scripts !== null &&
|
|
412
|
+
typeof scripts === 'object' &&
|
|
413
|
+
Object.values(scripts).some((v) => typeof v === 'string' && LERNA_RUN.test(v))
|
|
414
|
+
if (!runs) return false
|
|
415
|
+
if (typeof config['useNx'] === 'boolean') return config['useNx']
|
|
416
|
+
const major = /\d+/.exec(version)?.[0]
|
|
417
|
+
return major === undefined || Number(major) >= 6
|
|
418
|
+
}
|
package/src/nx/nx-deps.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type TaskNameFor = (project: string, target: string, configuration: strin
|
|
|
19
19
|
* "depends on …#codegen but no such task is declared". Dropped here as
|
|
20
20
|
* Nx drops it, for this project and a named one alike.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
type HasTarget = (project: string, target: string) => boolean
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The graph nodes a `projects` list names, ported from Nx's
|
package/src/nx/nx-inputs.ts
CHANGED
|
@@ -68,13 +68,9 @@ export function expandNxInputs(
|
|
|
68
68
|
todos.push(`input ${JSON.stringify(entry)} uses a token vx does not support`)
|
|
69
69
|
return
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'list it, so vx cannot key on it — dropped; the task that writes it keys its dependants through dependsOn',
|
|
75
|
-
)
|
|
76
|
-
return
|
|
77
|
-
}
|
|
71
|
+
// Nx's file map skips gitignored files, so it hashes nothing here
|
|
72
|
+
// either: a changed `dist` file is a cache hit under Nx 23.3 (YL-1).
|
|
73
|
+
if (neg === '' && at.outputs?.some((o) => p === o || p.startsWith(`${o}/`))) return
|
|
78
74
|
const g = minimatchToVx(p, neg !== '')
|
|
79
75
|
if (g === null) {
|
|
80
76
|
todos.push(`input ${JSON.stringify(entry)}: glob syntax vx cannot take — map manually`)
|
package/src/nx/nx-map.ts
CHANGED
|
@@ -800,6 +800,10 @@ function buildTask(
|
|
|
800
800
|
meta.packageJson,
|
|
801
801
|
opts.manifestField,
|
|
802
802
|
opts.executors,
|
|
803
|
+
(p, t) =>
|
|
804
|
+
!(p === nodeName && t === targetName) &&
|
|
805
|
+
metaByNode.has(p) &&
|
|
806
|
+
Object.hasOwn(nodeMap[p]?.data?.targets ?? {}, t),
|
|
803
807
|
)
|
|
804
808
|
|
|
805
809
|
const inputs = emptyNxInputs()
|
|
@@ -845,6 +849,12 @@ function buildTask(
|
|
|
845
849
|
: { node: nodeName, configuration: variant.configuration! },
|
|
846
850
|
options,
|
|
847
851
|
)
|
|
852
|
+
// A nested `nx <target>` runs the target's default configuration, whatever this one runs.
|
|
853
|
+
for (const c of mapped?.nxCalls ?? []) {
|
|
854
|
+
const edge =
|
|
855
|
+
c.project === nodeName ? c.target : `${metaByNode.get(c.project)!.name}#${c.target}`
|
|
856
|
+
if (!deps.includes(edge)) deps.push(edge)
|
|
857
|
+
}
|
|
848
858
|
|
|
849
859
|
// Nx's rule, not a guess: a target is cached when it says `cache: true`
|
|
850
860
|
// (Nx ≥ 17 writes it into the graph from `cacheableOperations` too —
|
|
@@ -1003,6 +1013,8 @@ interface MappedCommand {
|
|
|
1003
1013
|
readonly envInputs: readonly string[]
|
|
1004
1014
|
/** A translated executor's `exec.timeout`. */
|
|
1005
1015
|
readonly timeout?: number
|
|
1016
|
+
/** The Nx targets a leading `nx <target> <project>` ran: dependency edges (nx-command.ts). */
|
|
1017
|
+
readonly nxCalls?: readonly { readonly project: string; readonly target: string }[]
|
|
1006
1018
|
}
|
|
1007
1019
|
|
|
1008
1020
|
/**
|
|
@@ -1025,6 +1037,7 @@ function mapCommand(
|
|
|
1025
1037
|
manifest: { readonly name?: unknown; readonly version?: unknown },
|
|
1026
1038
|
manifestField: MapNxOptions['manifestField'],
|
|
1027
1039
|
executors: NxExecutors | undefined,
|
|
1040
|
+
isTarget: (project: string, target: string) => boolean,
|
|
1028
1041
|
): MappedCommand | null {
|
|
1029
1042
|
const executor = target.executor
|
|
1030
1043
|
if (executor === 'nx:noop') {
|
|
@@ -1063,7 +1076,7 @@ function mapCommand(
|
|
|
1063
1076
|
if (executor === 'nx:run-commands' || plain) {
|
|
1064
1077
|
const rc = mapRunCommands(
|
|
1065
1078
|
plain ? { ...options, command: target.command } : options,
|
|
1066
|
-
{ projectRel, projectName },
|
|
1079
|
+
{ projectRel, projectName, isTarget },
|
|
1067
1080
|
todos,
|
|
1068
1081
|
)
|
|
1069
1082
|
if (rc === null) return line(PLACEHOLDER)
|
|
@@ -1075,7 +1088,10 @@ function mapCommand(
|
|
|
1075
1088
|
: path.posix.isAbsolute(rc.envFile)
|
|
1076
1089
|
? rc.envFile
|
|
1077
1090
|
: relPosix(projectRel, path.posix.normalize(rc.envFile))
|
|
1078
|
-
return
|
|
1091
|
+
return {
|
|
1092
|
+
...shell(rc.command, envFile, { env: rc.env, readyWhen: rc.readyWhen }, rc.readyAll),
|
|
1093
|
+
...(rc.nxCalls === undefined ? {} : { nxCalls: rc.nxCalls }),
|
|
1094
|
+
}
|
|
1079
1095
|
}
|
|
1080
1096
|
if (executor === 'nx:run-script') {
|
|
1081
1097
|
const script = typeof options.script === 'string' ? options.script : targetName
|
package/src/nx-command.ts
CHANGED
|
@@ -40,13 +40,15 @@ interface NxCommandContext {
|
|
|
40
40
|
readonly projectRel: string
|
|
41
41
|
/** The Nx project name, what `{projectName}` expands to. */
|
|
42
42
|
readonly projectName: string
|
|
43
|
+
/** Whether a vx task runs Nx project `project`'s `target`: what a leading `nx <target> <project>` may become. */
|
|
44
|
+
readonly isTarget?: (project: string, target: string) => boolean
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** The line `nx-env --ready-when` prints once every string is seen (`READY` in nx-env.cjs). */
|
|
46
48
|
const NX_ENV_READY = '^nx-env: ready$'
|
|
47
49
|
|
|
48
50
|
/** A run-commands target as vx runs it. */
|
|
49
|
-
|
|
51
|
+
interface RunCommandsLine {
|
|
50
52
|
readonly command: string
|
|
51
53
|
/** `exec.env.define`: the target's `env`, and `FORCE_COLOR` under `color`. */
|
|
52
54
|
readonly env: Readonly<Record<string, string>>
|
|
@@ -60,8 +62,18 @@ export interface RunCommandsLine {
|
|
|
60
62
|
readonly readyAll?: readonly string[]
|
|
61
63
|
/** `envFile` as declared: workspace-root-relative, or absolute. */
|
|
62
64
|
readonly envFile: string | undefined
|
|
65
|
+
/**
|
|
66
|
+
* The Nx targets the line's leading `nx <target> <project>` /
|
|
67
|
+
* `nx run <project>:<target>` commands ran, taken out of the line: each
|
|
68
|
+
* is a dependency edge instead.
|
|
69
|
+
*/
|
|
70
|
+
readonly nxCalls?: readonly { readonly project: string; readonly target: string }[]
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
/** `nx <target> <project>` or `nx run <project>:<target>`, through a package runner or not, with nothing else. */
|
|
74
|
+
const NX_CALL =
|
|
75
|
+
/^(?:(?:npx|bunx|pnpm(?: exec)?|yarn)\s+)?nx\s+(?:run\s+([\w@./-]+):([\w:.-]+)|([\w:.-]+)\s+([\w@./-]+))$/
|
|
76
|
+
|
|
65
77
|
/** The options run-commands consumes itself; every other one is forwarded to each command. */
|
|
66
78
|
const PROP_KEYS: ReadonlySet<string> = new Set([
|
|
67
79
|
'command',
|
|
@@ -306,8 +318,6 @@ export function mapRunCommands(
|
|
|
306
318
|
'nx:run-commands: per-command `prefix` / `color` output decoration is not reproduced',
|
|
307
319
|
)
|
|
308
320
|
}
|
|
309
|
-
if (entries.length === 0) return { command: NOOP, env, readyWhen: undefined, envFile }
|
|
310
|
-
|
|
311
321
|
const argsOption = Array.isArray(options['args'])
|
|
312
322
|
? options['args'].join(' ')
|
|
313
323
|
: typeof options['args'] === 'string'
|
|
@@ -328,6 +338,27 @@ export function mapRunCommands(
|
|
|
328
338
|
? options['forwardAllArgs']
|
|
329
339
|
: true
|
|
330
340
|
|
|
341
|
+
// A command that runs another target under Nx, ahead of the rest of a
|
|
342
|
+
// serial line (ngrx's `build` opens with `nx build-package <project>`),
|
|
343
|
+
// ran Nx inside the task and kept two cached tasks on one output with no
|
|
344
|
+
// edge between them; as an edge, the target runs first as Nx ran it. One
|
|
345
|
+
// Nx would hand options or arguments to stays a command.
|
|
346
|
+
const nxCalls: { project: string; target: string }[] = []
|
|
347
|
+
if ((!parallel || entries.length === 1) && readyWhen.length === 0 && ctx.isTarget) {
|
|
348
|
+
while (entries.length > 0) {
|
|
349
|
+
const e = entries[0]!
|
|
350
|
+
const m = NX_CALL.exec(e.command.trim())
|
|
351
|
+
if (m === null || (forwardAll(e) && (unknown.length > 0 || argsOption))) break
|
|
352
|
+
const project = m[1] ?? m[4]!
|
|
353
|
+
const target = m[2] ?? m[3]!
|
|
354
|
+
if (!ctx.isTarget(project, target)) break
|
|
355
|
+
nxCalls.push({ project, target })
|
|
356
|
+
entries.shift()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const calls = nxCalls.length > 0 ? { nxCalls } : {}
|
|
360
|
+
if (entries.length === 0) return { command: NOOP, env, readyWhen: undefined, envFile, ...calls }
|
|
361
|
+
|
|
331
362
|
const commands: Interpolated[] = []
|
|
332
363
|
let named = false
|
|
333
364
|
for (const e of entries) {
|
|
@@ -384,7 +415,7 @@ export function mapRunCommands(
|
|
|
384
415
|
const only = commands.length === 1 ? commands[0]! : undefined
|
|
385
416
|
if (only !== undefined && only.runtime === 'append' && only.baked.length === 0) {
|
|
386
417
|
const line = only.tail === undefined ? only.text : `${only.text} ${only.tail}`
|
|
387
|
-
return { command: `${cd}${line}`, env, readyWhen: pattern, envFile, ...ready }
|
|
418
|
+
return { command: `${cd}${line}`, env, readyWhen: pattern, envFile, ...ready, ...calls }
|
|
388
419
|
}
|
|
389
420
|
// A subshell per command, as Nx gives each a shell of its own; a comment
|
|
390
421
|
// in one must not swallow the `)` that closes it.
|
|
@@ -437,6 +468,7 @@ export function mapRunCommands(
|
|
|
437
468
|
readyWhen: pattern,
|
|
438
469
|
envFile,
|
|
439
470
|
...ready,
|
|
471
|
+
...calls,
|
|
440
472
|
}
|
|
441
473
|
}
|
|
442
474
|
|
package/src/nx-env.cjs
CHANGED
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
const { spawn, spawnSync } = require('node:child_process')
|
|
26
26
|
const { createRequire } = require('node:module')
|
|
27
27
|
const path = require('node:path')
|
|
28
|
-
const {
|
|
28
|
+
const { realpathSync } = require('node:fs')
|
|
29
|
+
// Through npm's `.bin` symlink under `--preserve-symlinks-main`, Node keeps
|
|
30
|
+
// the link as the main module, so a relative require would look in `.bin`.
|
|
31
|
+
const { loadTaskEnv } = require(path.join(path.dirname(realpathSync(__filename)), 'nx-dotenv.cjs'))
|
|
29
32
|
|
|
30
33
|
// `--envFile`, Nx's own option name, and not `--env-file`: Node 22 takes
|
|
31
34
|
// `--env-file` from anywhere on its command line, the script's arguments
|
package/src/nx-exec.cjs
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
const { createRequire, enableCompileCache } = require('node:module')
|
|
23
23
|
const path = require('node:path')
|
|
24
|
-
const {
|
|
24
|
+
const { realpathSync } = require('node:fs')
|
|
25
|
+
// Through npm's `.bin` symlink under `--preserve-symlinks-main`, Node keeps
|
|
26
|
+
// the link as the main module, so a relative require would look in `.bin`.
|
|
27
|
+
const { loadTaskEnv } = require(path.join(path.dirname(realpathSync(__filename)), 'nx-dotenv.cjs'))
|
|
25
28
|
|
|
26
29
|
// Nx's module graph is ~150 ms of every executed task; Node's on-disk
|
|
27
30
|
// compile cache (22.1+, a no-op below) takes ~30 of them back on the
|
package/src/paths.ts
CHANGED
|
@@ -27,10 +27,12 @@ export function relPosix(from: string, to: string): string {
|
|
|
27
27
|
* holds: a string or an array would enumerate its INDICES as script names,
|
|
28
28
|
* and indexing one by a task named `0` yields a single character that reads
|
|
29
29
|
* as a usable command. Core's `migrate-scripts.ts` guards this read; these
|
|
30
|
-
* mappers did not.
|
|
30
|
+
* mappers did not. Null-prototype, so a turbo.json task or Nx target named
|
|
31
|
+
* `constructor` or `toString` finds no script rather than Object's method.
|
|
31
32
|
*/
|
|
32
33
|
export function packageScripts(meta: ProjectMeta): Record<string, unknown> {
|
|
33
34
|
const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const scripts: Record<string, unknown> = Object.create(null)
|
|
36
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return scripts
|
|
37
|
+
return Object.assign(scripts, raw)
|
|
36
38
|
}
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { lstat, readFile } from 'node:fs/promises'
|
|
11
11
|
import path from 'node:path'
|
|
12
|
-
import { outputsOverlap } from '@vzn/vx'
|
|
12
|
+
import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
|
|
13
13
|
|
|
14
14
|
/** The files git tracks under `root`, root-relative; null outside a repo or without git. */
|
|
15
15
|
export async function trackedFiles(root: string): Promise<string[] | null> {
|
|
@@ -216,6 +216,27 @@ export function spareTrackedOutputs(
|
|
|
216
216
|
tracked: readonly string[],
|
|
217
217
|
): [string, string][] {
|
|
218
218
|
const todos: [string, string][] = []
|
|
219
|
+
const trackedSet = new Set(tracked)
|
|
220
|
+
/** Drop the `!` entries readers carry for a committed file no output names any more. */
|
|
221
|
+
const unhide = (owner: (typeof projects)[number] | null, rooted: string) => {
|
|
222
|
+
for (const q of projects) {
|
|
223
|
+
const qrel = path.relative(root, q.dir).split(path.sep).join('/')
|
|
224
|
+
for (const r of q.tasks) {
|
|
225
|
+
const inputs = (r.task?.['cache'] as { inputs?: Outputs } | undefined)?.inputs
|
|
226
|
+
if (inputs === undefined) continue
|
|
227
|
+
const own =
|
|
228
|
+
q === owner &&
|
|
229
|
+
Array.isArray(inputs.files) &&
|
|
230
|
+
(qrel === '' || rooted.startsWith(`${qrel}/`))
|
|
231
|
+
? rooted.slice(qrel === '' ? 0 : qrel.length + 1)
|
|
232
|
+
: undefined
|
|
233
|
+
if (own !== undefined)
|
|
234
|
+
inputs.files = (inputs.files as unknown[]).filter((g) => g !== `!${own}`)
|
|
235
|
+
if (Array.isArray(inputs.workspaceFiles))
|
|
236
|
+
inputs.workspaceFiles = inputs.workspaceFiles.filter((g) => g !== `!${rooted}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
219
240
|
for (const p of projects) {
|
|
220
241
|
const rel = path.relative(root, p.dir).split(path.sep).join('/')
|
|
221
242
|
const own = rel === '' ? tracked : filesUnder(tracked, rel).map((f) => f.slice(rel.length + 1))
|
|
@@ -229,7 +250,20 @@ export function spareTrackedOutputs(
|
|
|
229
250
|
] as const) {
|
|
230
251
|
const globs = outputs[key]
|
|
231
252
|
if (!Array.isArray(globs) || globs.length === 0) continue
|
|
232
|
-
|
|
253
|
+
let strings = globs.filter((g): g is string => typeof g === 'string')
|
|
254
|
+
// An output naming one committed file (eslint's
|
|
255
|
+
// `eslint-suppressions.json`) is a source: its `!` would take the
|
|
256
|
+
// whole entry back, so the entry goes, and so does the `!` a
|
|
257
|
+
// reader's inputs got for it, or every reader ran uncached (hono).
|
|
258
|
+
const rootedOf = (g: string) => (key === 'files' && rel !== '' ? `${rel}/${g}` : g)
|
|
259
|
+
const sources = new Set(
|
|
260
|
+
strings.filter((g) => isLiteralPattern(g) && trackedSet.has(rootedOf(g))),
|
|
261
|
+
)
|
|
262
|
+
if (sources.size > 0) {
|
|
263
|
+
strings = strings.filter((g) => !sources.has(g.startsWith('!') ? g.slice(1) : g))
|
|
264
|
+
outputs[key] = strings
|
|
265
|
+
for (const g of sources) unhide(key === 'files' ? p : null, rootedOf(g))
|
|
266
|
+
}
|
|
233
267
|
const hit = coveredTracked(strings, base)
|
|
234
268
|
if (hit.length > 0) hits.push([key, strings, hit])
|
|
235
269
|
}
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
import { pruneOrphanPersistentNotes, type ProjectMeta, UserError } from '@vzn/vx'
|
|
16
|
-
import { minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
|
|
16
|
+
import { literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
|
|
17
17
|
import { shellQuote } from '../nx-command.js'
|
|
18
18
|
import { scriptCommand, yarnPnp } from '../script-command.js'
|
|
19
19
|
import {
|
|
@@ -500,6 +500,11 @@ function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
|
|
|
500
500
|
return cfg.tasks ?? cfg.pipeline ?? {}
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
/** A turbo.json's own task `key`: a plain index reads `constructor` off Object. */
|
|
504
|
+
function taskAt(tasks: Record<string, TurboTask>, key: string): TurboTask | undefined {
|
|
505
|
+
return Object.hasOwn(tasks, key) ? tasks[key] : undefined
|
|
506
|
+
}
|
|
507
|
+
|
|
503
508
|
function declares(meta: ProjectMeta, dependency: string): boolean {
|
|
504
509
|
const pj = meta.packageJson as unknown as Record<string, unknown>
|
|
505
510
|
// Turbo counts every kind but a peer in a monorepo.
|
|
@@ -627,7 +632,7 @@ function taskDefined(
|
|
|
627
632
|
const cfg = files.get(at)
|
|
628
633
|
if (cfg === undefined) return at === ROOT ? 'none' : taskDefined(pkgName, name, files, ROOT, seen)
|
|
629
634
|
const tasks = tasksOf(cfg)
|
|
630
|
-
const def = tasks
|
|
635
|
+
const def = taskAt(tasks, `${pkgName}#${name}`) ?? taskAt(tasks, name)
|
|
631
636
|
if (def !== undefined) return optedOut(def) ? 'excluded' : 'found'
|
|
632
637
|
if (at === ROOT) return 'none'
|
|
633
638
|
for (const parent of parentsOf(cfg)) {
|
|
@@ -666,7 +671,9 @@ function taskNamesFor(
|
|
|
666
671
|
function definitionOf(pkgName: string, name: string, chain: readonly TurboJson[]): TurboTask {
|
|
667
672
|
const defs = chain.map((cfg, i) => {
|
|
668
673
|
const tasks = tasksOf(cfg)
|
|
669
|
-
return i === 0
|
|
674
|
+
return i === 0
|
|
675
|
+
? (taskAt(tasks, `${pkgName}#${name}`) ?? taskAt(tasks, name))
|
|
676
|
+
: taskAt(tasks, name)
|
|
670
677
|
})
|
|
671
678
|
let from = 0
|
|
672
679
|
for (let i = defs.length - 1; i > 0; i--) {
|
|
@@ -676,16 +683,43 @@ function definitionOf(pkgName: string, name: string, chain: readonly TurboJson[]
|
|
|
676
683
|
}
|
|
677
684
|
}
|
|
678
685
|
let def: TurboTask = {}
|
|
686
|
+
let fromDeps: DependencyOutputs | null = null
|
|
679
687
|
for (const d of defs.slice(from)) {
|
|
680
688
|
if (d === undefined) continue
|
|
681
689
|
const own: TurboTask = { ...d }
|
|
682
690
|
delete own.extends
|
|
683
|
-
if (Array.isArray(own.inputs))
|
|
691
|
+
if (Array.isArray(own.inputs)) {
|
|
692
|
+
const found = dependencyOutputsOf(own.inputs)
|
|
693
|
+
if (found !== null || !own.inputs.includes(TURBO_EXTENDS)) fromDeps = found
|
|
694
|
+
own.inputs = flatInputs(own.inputs)
|
|
695
|
+
}
|
|
684
696
|
def = withOverlay(def, own)
|
|
685
697
|
}
|
|
698
|
+
if (fromDeps !== null) dependencyOutputs.set(def, fromDeps)
|
|
686
699
|
return def
|
|
687
700
|
}
|
|
688
701
|
|
|
702
|
+
/** A task's `{ mode: "dependencyOutputs" }` input: the producers it names, and which of their files. */
|
|
703
|
+
interface DependencyOutputs {
|
|
704
|
+
from: readonly string[] | null
|
|
705
|
+
globs: readonly string[]
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Held beside a resolved definition, out of the keys the unknown-key check reads. */
|
|
709
|
+
const dependencyOutputs = new WeakMap<TurboTask, DependencyOutputs>()
|
|
710
|
+
|
|
711
|
+
function dependencyOutputsOf(inputs: readonly unknown[]): DependencyOutputs | null {
|
|
712
|
+
for (const i of inputs) {
|
|
713
|
+
if (i === null || typeof i !== 'object') continue
|
|
714
|
+
const { mode, from, globs } = i as { mode?: unknown; from?: unknown; globs?: unknown }
|
|
715
|
+
if (mode !== 'dependencyOutputs') continue
|
|
716
|
+
const strings = (v: unknown): string[] =>
|
|
717
|
+
Array.isArray(v) ? v.filter((g): g is string => typeof g === 'string') : []
|
|
718
|
+
return { from: Array.isArray(from) ? strings(from) : null, globs: strings(globs) }
|
|
719
|
+
}
|
|
720
|
+
return null
|
|
721
|
+
}
|
|
722
|
+
|
|
689
723
|
/**
|
|
690
724
|
* Turbo 2.11's structured inputs as the strings they stand for: a
|
|
691
725
|
* `startup` or `jit` entry is its globs (and every file with
|
|
@@ -808,7 +842,7 @@ function rootDependencyGlobs(
|
|
|
808
842
|
const out: string[] = []
|
|
809
843
|
for (const name of seen) {
|
|
810
844
|
const rel = relPosix(root, byName.get(name)!.dir)
|
|
811
|
-
if (rel !== '' && rel !== '.') out.push(`${rel}/**`)
|
|
845
|
+
if (rel !== '' && rel !== '.') out.push(`${literalGlob(rel)}/**`)
|
|
812
846
|
}
|
|
813
847
|
return out.sort()
|
|
814
848
|
}
|
|
@@ -1076,22 +1110,15 @@ export async function mapTurboWorkspace(
|
|
|
1076
1110
|
// A name no package has a script for is an entry point of its own:
|
|
1077
1111
|
// `turbo run ci` over `ci: { dependsOn: ["lint", "build"] }`, or
|
|
1078
1112
|
// cal.com's `deploy` → `@calcom/web#build`, runs its edges, and vx
|
|
1079
|
-
// said no project declares it.
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1113
|
+
// said no project declares it. With no edge left (documenso's `lint`,
|
|
1114
|
+
// shadcn-ui's `check`) Turbo still runs a no-op node per package and
|
|
1115
|
+
// exits 0, so a CI step `vx run lint` must too. Its `pkg#task` edges
|
|
1116
|
+
// stay in that package alone: core's `--filter`/`--affected` reach
|
|
1117
|
+
// follows a task edge across packages, and cal.com's `deploy` in all
|
|
1118
|
+
// 116 would have made every package a dependent of web.
|
|
1083
1119
|
else if (!scripted.has(name)) {
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
if (envDependency(d) !== null || d.includes('$TURBO_ROOT$')) return false
|
|
1087
|
-
if (d.startsWith('^'))
|
|
1088
|
-
return d !== `^${name}` && [...runnable.values()].some((r) => r.has(d.slice(1)))
|
|
1089
|
-
return d !== name && (own.has(d) || defined.has(d))
|
|
1090
|
-
})
|
|
1091
|
-
if (keeps) {
|
|
1092
|
-
emitted.get(meta.name)!.add(name)
|
|
1093
|
-
entry.add(`${meta.name}#${name}`)
|
|
1094
|
-
}
|
|
1120
|
+
emitted.get(meta.name)!.add(name)
|
|
1121
|
+
entry.add(`${meta.name}#${name}`)
|
|
1095
1122
|
}
|
|
1096
1123
|
}
|
|
1097
1124
|
}
|
|
@@ -1381,6 +1408,67 @@ export async function mapTurboWorkspace(
|
|
|
1381
1408
|
projects.push({ name: meta.name, dir: meta.dir, tasks })
|
|
1382
1409
|
}
|
|
1383
1410
|
|
|
1411
|
+
// Turbo 2.11's `dependencyOutputs` input hashes the named files after
|
|
1412
|
+
// their producer ran. A cached producer's key already reaches the reader
|
|
1413
|
+
// through `dependsOn`, its outputs following from it; an uncached one's
|
|
1414
|
+
// (vercel/vercel's `//#generate:cache-keys`, which records the host) do
|
|
1415
|
+
// not, and a test cached on one platform hit on another. The reader
|
|
1416
|
+
// keys those files by a probe at the workspace root.
|
|
1417
|
+
const metaNamed = new Map(metas.map((m) => [m.name, m]))
|
|
1418
|
+
for (const meta of metas) {
|
|
1419
|
+
const { defFor } = definitions(meta)
|
|
1420
|
+
for (const t of projects.find((p) => p.name === meta.name)?.tasks ?? []) {
|
|
1421
|
+
const def = defFor(t.name)
|
|
1422
|
+
const dep = def === undefined ? undefined : dependencyOutputs.get(def)
|
|
1423
|
+
const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
|
|
1424
|
+
if (dep === undefined || inputs === undefined) continue
|
|
1425
|
+
const rels: string[] = []
|
|
1426
|
+
const unfold = new Set<string>()
|
|
1427
|
+
for (const sel of dep.from ?? def!.dependsOn ?? []) {
|
|
1428
|
+
if (envDependency(sel) !== null) continue
|
|
1429
|
+
const hash = sel.indexOf('#')
|
|
1430
|
+
const producers: [ProjectMeta | undefined, string][] = sel.startsWith('^')
|
|
1431
|
+
? metas.filter((m) => declares(meta, m.name)).map((m) => [m, sel.slice(1)])
|
|
1432
|
+
: hash === -1
|
|
1433
|
+
? [[meta, sel]]
|
|
1434
|
+
: [
|
|
1435
|
+
[
|
|
1436
|
+
sel.startsWith(`${ROOT}#`) ? rootMeta : metaNamed.get(sel.slice(0, hash)),
|
|
1437
|
+
sel.slice(hash + 1),
|
|
1438
|
+
],
|
|
1439
|
+
]
|
|
1440
|
+
for (const [pm, task] of producers) {
|
|
1441
|
+
if (pm === undefined) continue
|
|
1442
|
+
const pdef = definitions(pm).defFor(task)
|
|
1443
|
+
if (pdef?.cache !== false) continue
|
|
1444
|
+
if (!sel.startsWith('^')) unfold.add(`${pm.name}#${task}`)
|
|
1445
|
+
const at = relPosix(root, pm.dir)
|
|
1446
|
+
for (const g of dep.globs.length > 0 ? dep.globs : (pdef.outputs ?? []))
|
|
1447
|
+
if (!g.startsWith('!')) rels.push(at === '' ? g : `${at}/${g}`)
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (rels.length === 0) continue
|
|
1451
|
+
const probe = rels.every(isLiteral) ? ignoredFilesProbe(rels) : dotenvGlobsProbe(rels)
|
|
1452
|
+
if (probe === null) {
|
|
1453
|
+
delete t.task!['cache']
|
|
1454
|
+
t.todos.push(
|
|
1455
|
+
`inputs read ${rels.map((r) => JSON.stringify(r)).join(', ')} from an uncached ` +
|
|
1456
|
+
'dependency, a glob vx cannot key by a probe — task runs uncached; key it in a vx.config to cache it',
|
|
1457
|
+
)
|
|
1458
|
+
continue
|
|
1459
|
+
}
|
|
1460
|
+
const probes = (inputs['workspaceRuntime'] as unknown[] | undefined) ?? []
|
|
1461
|
+
inputs['workspaceRuntime'] = [...probes, probe]
|
|
1462
|
+
// Folded, the producer's key "covers" what it writes, and core answers
|
|
1463
|
+
// the probe before it runs: the previous run's bytes. Left out of the
|
|
1464
|
+
// fold, the probe waits for it (core's late probes, X-34).
|
|
1465
|
+
const deps = (t.task!['dependsOn'] as string[] | undefined) ?? []
|
|
1466
|
+
const id = (d: string) => (d.startsWith('^') || d.includes('#') ? d : `${meta.name}#${d}`)
|
|
1467
|
+
if (deps.some((d) => unfold.has(id(d))))
|
|
1468
|
+
inputs['tasks'] = deps.filter((d) => !unfold.has(id(d)))
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1384
1472
|
if (opts.ignored !== undefined) {
|
|
1385
1473
|
const ignored = await opts.ignored([
|
|
1386
1474
|
...new Set(literals.tasks.flatMap((l) => [...l.rels.keys()])),
|
|
@@ -1635,7 +1723,7 @@ function buildTask(
|
|
|
1635
1723
|
const climbed = (glob: string): string | null => {
|
|
1636
1724
|
const body = glob.startsWith('!') ? glob.slice(1) : glob
|
|
1637
1725
|
if (!body.startsWith('../')) return null
|
|
1638
|
-
const anchored = path.posix.normalize(path.posix.join(pkgDir, body))
|
|
1726
|
+
const anchored = path.posix.normalize(path.posix.join(literalGlob(pkgDir), body))
|
|
1639
1727
|
if (anchored.startsWith('../')) return null
|
|
1640
1728
|
return (glob.startsWith('!') ? '!' : '') + anchored
|
|
1641
1729
|
}
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -353,7 +353,8 @@ const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
|
|
|
353
353
|
* The seam implementation: `has` is HEAD, `hasMany` is the batch query,
|
|
354
354
|
* `get`/`put` carry `x-artifact-duration` (and the tag when signing). An
|
|
355
355
|
* auth failure (401/403) throws ONCE — LayeredCache reports it — and then
|
|
356
|
-
* turns the layer off for the rest of the process
|
|
356
|
+
* turns the layer off for the rest of the process (an upload's 403 turns off
|
|
357
|
+
* uploads alone: the read-only token), so a bad token costs one
|
|
357
358
|
* line, not one per task — the requests already in flight when it lands
|
|
358
359
|
* degrade in silence rather than repeating it.
|
|
359
360
|
*
|
|
@@ -364,6 +365,13 @@ const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
|
|
|
364
365
|
*/
|
|
365
366
|
export class TurboRemoteCache implements RemoteCacheLayer {
|
|
366
367
|
private disabled = false
|
|
368
|
+
/**
|
|
369
|
+
* A `403` on an upload is a read-only token or server
|
|
370
|
+
* (turborepo-remote-cache's `READ_ONLY` and its JWTs without the write
|
|
371
|
+
* scope): uploads stop, reads go on. Read as a refused token, it turned
|
|
372
|
+
* the reads off too, and every task after the first upload missed.
|
|
373
|
+
*/
|
|
374
|
+
private writesRefused = false
|
|
367
375
|
private readonly key: Uint8Array | undefined
|
|
368
376
|
readonly endpoint: string
|
|
369
377
|
constructor(
|
|
@@ -422,6 +430,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
422
430
|
).catch((err: unknown) => {
|
|
423
431
|
throw deadlineNamed(err, timeoutMs)
|
|
424
432
|
})
|
|
433
|
+
if (method === 'PUT' && res.status === 403) {
|
|
434
|
+
const first = !this.writesRefused
|
|
435
|
+
this.writesRefused = true
|
|
436
|
+
if (!first) return undefined
|
|
437
|
+
throw new Error(
|
|
438
|
+
'HTTP 403: the upload was refused (a read-only token or server); uploads off for this run, reads go on',
|
|
439
|
+
)
|
|
440
|
+
}
|
|
425
441
|
if (res.status === 401 || res.status === 403) {
|
|
426
442
|
const first = !this.disabled
|
|
427
443
|
this.disabled = true
|
|
@@ -520,7 +536,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
520
536
|
}
|
|
521
537
|
|
|
522
538
|
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
523
|
-
if (this.disabled) return
|
|
539
|
+
if (this.disabled || this.writesRefused) return
|
|
524
540
|
const headers: Record<string, string> = {
|
|
525
541
|
'Content-Type': 'application/octet-stream',
|
|
526
542
|
'Content-Length': String(body.size),
|
package/src/workspace-notes.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function lockfilePlugin(root: string): { file: string; factory: string }
|
|
|
24
24
|
return lock === undefined ? undefined : { file: lock[0], factory: lock[1] }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
interface AdoptedTool {
|
|
28
28
|
/** The adoption plugin `vx init` declares: `turbo`, `nx`. */
|
|
29
29
|
readonly plugin: string
|
|
30
30
|
/** The file it reads every run: `turbo.json`, `nx.json`. */
|
package/src/workspace-plugins.ts
CHANGED
|
@@ -45,6 +45,7 @@ function entries(plugins: readonly WorkspacePlugin[], indent: string): string[]
|
|
|
45
45
|
export function renderWorkspaceFile(
|
|
46
46
|
plugins: readonly WorkspacePlugin[],
|
|
47
47
|
format: 'ts' | 'mjs',
|
|
48
|
+
fields: readonly { readonly field: string; readonly source: string }[] = [],
|
|
48
49
|
): string {
|
|
49
50
|
return [
|
|
50
51
|
...(format === 'ts' ? ["import type { WorkspaceConfig } from '@vzn/vx/config'"] : []),
|
|
@@ -54,6 +55,7 @@ export function renderWorkspaceFile(
|
|
|
54
55
|
' plugins: [',
|
|
55
56
|
...entries(plugins, ' '),
|
|
56
57
|
' ],',
|
|
58
|
+
...fields.map((f) => ` ${f.field}: ${f.source},`),
|
|
57
59
|
`}${format === 'ts' ? ' satisfies WorkspaceConfig' : ''}`,
|
|
58
60
|
'',
|
|
59
61
|
].join('\n')
|