@vzn/vx-migrate 0.0.637 → 0.0.639
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 +11 -9
- package/package.json +2 -2
- package/src/adopt.ts +5 -3
- package/src/adoption-plugin.ts +2 -2
- package/src/mapping-cache.ts +3 -1
- package/src/migrate-nx.ts +47 -7
- package/src/migrate.ts +144 -80
- package/src/nx/export-graph.ts +15 -4
- package/src/nx/index.ts +5 -3
- package/src/plugin-gaps.ts +20 -5
- package/src/shared-outputs.ts +45 -6
- package/src/turbo-cache/index.ts +28 -3
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Until `bunx @vzn/vx-migrate` writes native config, `vx run build --all` maps eve
|
|
|
43
43
|
### What it does not do
|
|
44
44
|
|
|
45
45
|
- A task the package's own `vx.config` already declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running from `turbo.json`.
|
|
46
|
-
- The mapping's gaps are the migration's gaps, reported as warnings on
|
|
46
|
+
- The mapping's gaps are the migration's gaps, reported as warnings by `vx lock` (and `vx lock --check`), never on a run, instead of `TODO(vx-migrate)` comments: `$TURBO_ROOT$` tasks, an env name vx cannot key that the run's environment sets (`FOO_?`, which Turbo reads literally), unknown turbo keys. A persistent task's readiness note (`readyWhen`) is reported only when something depends on it — with no dependent there is nothing to gate (refine printed it for 375 `dev` targets a run). `bunx @vzn/vx-migrate --dry` lists the same set once. Each gap is one line for every task that carries it, not one per task (n8n marks `dev` and `watch` persistent in most of its 84 packages; astro negates `vendor/**` in the outputs of 57 tasks). A per-package `{ "extends": false }` with nothing else is Turbo's opt-out and maps to no task; with keys, the task runs on those keys alone. A glob that climbs out of the package (`../../packages/app-store/*.generated.ts`) is re-anchored on the workspace root as a `workspaceFiles` glob. A negated output under a literal-rooted one (`dist/**` minus `!dist/**/*.map`) takes its paths back (see `outputs` below); one against a wildcard-rooted output (medusa's `*/**` minus `!src/**`) makes the task uncached, because the clean before exec would otherwise delete the sources — declare the exact outputs in a vx.config to cache it.
|
|
47
47
|
- The cost is the stage's: on a 1,000-package workspace with no `vx.config` files the mapping loads in about 42 ms where 1,000 evaluated configs load in 22.
|
|
48
48
|
- The mapping is kept in `<cache dir>/vx-migrate-turbo-mapping.json`, keyed on every `turbo.json` / `turbo.jsonc`, every package manifest, the mapper's own code and the environment variable names a wildcard can match (one in a Turbo config or Turbo's framework table) or core cannot key, so an unrelated variable keeps it; a run with none changed reads it instead of mapping (astro, 562 packages: ~40 ms of mapping against ~12 ms of key reads).
|
|
49
49
|
- The mapping is read once per run, never once per process: under `vx watch`, a `package.json` script edit or a per-package `turbo.json` overlay edit is the next cycle's tasks. The root `turbo.json` is no task's input and lives in no project dir, so an edit to it is not a cycle — restart the watch.
|
|
@@ -62,11 +62,11 @@ Rules:
|
|
|
62
62
|
- A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: a keyed group (no command, nothing spawns) keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` of their own). A dependency without the script whose own edges lack `^x` gets it too, so a `^x` stops there as Turbo's does (opencode's `build: { dependsOn: [] }`: core walked past to the builds below, which Turbo's `^build` never reaches).
|
|
63
63
|
- `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
|
|
64
64
|
- `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx 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 wildcard `X/**` input, `!` or not, also lists `X`, as outputs do: Turbo's `**` matches zero segments, so `*/**` keys on the package-root files too. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`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.
|
|
65
|
-
- `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). A wildcard `X/**` also lists `X`: Turbo's `**` matches zero segments, so medusa's `*/**` caches the package-root `tsconfig.tsbuildinfo` too (its `package.json` is taken back, below); a literal `dist/**` needs no `dist`. An output whose first segment is a wildcard (`**/*.d.ts`, medusa's `*/**`) stays cached, as in `nx()` (`turbo()` 2026-10-14): Turbo never cleans an output, vx cleans it before every run and every restore, and such a glob can reach the sources; core never cleans a committed file, and every untracked file it reaches is deleted, which
|
|
65
|
+
- `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). A wildcard `X/**` also lists `X`: Turbo's `**` matches zero segments, so medusa's `*/**` caches the package-root `tsconfig.tsbuildinfo` too (its `package.json` is taken back, below); a literal `dist/**` needs no `dist`. An output whose first segment is a wildcard (`**/*.d.ts`, medusa's `*/**`) stays cached, as in `nx()` (`turbo()` 2026-10-14): Turbo never cleans an output, vx cleans it before every run and every restore, and such a glob can reach the sources; core never cleans a committed file, and every untracked file it reaches is deleted, which `vx lock`'s warnings name (`output "*/**": … every untracked file this glob reaches is deleted`). Negations beside it (`!src/**`) keep what they name. Without git the files cannot be told apart, and the task runs uncached with a todo. A glob that reaches no tracked kind gets no warning: one segment with a literal extension the package tracks no file of (`*.xml`), `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`), and a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) takes the manifest back with `!package.json` and keeps the cache, in `turbo()` as in `nx()` (owner, 2026-10-03): vx would otherwise delete it before every run. The build rewrites a file it may list as an input (`@vzn/vx-lockfile` lists `package.json`), so the Turbo mapping also sets `inputStability: { strategy: 'mutable' }` and an identical rewrite counts as unchanged. So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached the same way (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) needs nothing: core never cleans a file git tracks, so it survives and the task keeps its cache.
|
|
66
66
|
- `env` / `passThroughEnv`: explicit names go to `cache.inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once. A name that is no shell variable name (`p-q`) is keyed but left out of `exec.env.passThrough`, as `sh` drops it before the task runs.
|
|
67
67
|
- Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
|
|
68
68
|
|
|
69
|
-
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. Same rule for Nx targets.
|
|
69
|
+
- **Two tasks of one package on one output path** (strapi's `build`, `build:code` and `build:types`, all on `dist/**`): vx cleans a task's outputs before it runs and before a restore, so the loader refuses two cached tasks whose outputs provably overlap. Under vx's default `rules.exclusiveOutputs` an edge between the two changes nothing (twenty's `build:individual` writing into `build`'s `dist`). The mapping resolves it before the file is written — `build` keeps its cache, else the task with a `^` edge, else the first declared (typescript-eslint's `typecheck` has `^typecheck` and writes declarations into `build`'s `dist`: keeping it left `build` uncached); the rest run uncached with a todo naming the keeper and the fix, their own output path. A shared tsc incremental file (create-t3-turbo's `build` and `typecheck` both on `.cache/tsbuildinfo.json`) stays with the keeper alone, and the other task keeps its cache when it emits nothing else (a typecheck); one that emits more runs uncached, as `tsc --incremental` skips files the keeper's tsbuildinfo calls built. A keeper whose output is a directory at any depth (solid's build: `**/dist`) meets a sibling's tree (`coverage/**`) only at a `dist` inside it: the keeper takes that tree back with `!coverage/**` and both keep their cache, as under Turbo. Same rule for Nx targets.
|
|
70
70
|
- **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, and a workspace output another package writes into this one is taken back from its package inputs too; Turbo and Nx hash what git tracks, and a build's outputs are not, so the key reads what the adopted tool read. A literal input another task writes cannot be taken back: that task runs uncached with a todo. A committed file under such an output is still a source: a reader the `!` would hide it from runs uncached with a todo. A keyed group (an `nx-input:<name>` twin, a transit node) runs nothing, so core lets it read what a task writes: it gets no `!` for a literal, the `!` that would hide a committed file goes, and it keeps its key (typescript-eslint's ast-spec commits the `*.shot` snapshots its `test` outputs, and its twin was written as `{}`, which core refuses). An output that names one committed file (eslint's `eslint-suppressions.json` in a `lint` task's outputs) is dropped, and so is the `!` each reader got for it: the file stays in every key, as Turbo and Nx hash it, and the readers stay cached. Same for Nx targets.
|
|
71
71
|
- **Two packages' tasks on one workspace output** (cal.com's shared `post-install` writes `../../node_modules/@prisma/client/**` from every package with the script): core refuses two cached tasks on one path with no edge between them, so `turbo()` (and `nx()`) keeps the first (in package and task order) cached and runs the rest uncached, each with a todo naming the keeper. A package's own outputs count at their workspace path: typescript-eslint's root project caches `dist` and each package's typecheck `dist/packages/<name>`, one path twice. Edges across packages are not read, so an ordered pair loses its cache too.
|
|
72
72
|
- `cache: false` or `persistent: true` → no `cache` block; a persistent task gets `exec.persistent: {}` and, when some task depends on it, the consumer's `persistentTodo`.
|
|
@@ -188,12 +188,12 @@ Once per run the plugin keys its snapshot (`<cache dir>/nx-project-graph.json`)
|
|
|
188
188
|
### What it does not do
|
|
189
189
|
|
|
190
190
|
- A task the package's own `vx.config` already declares is left alone — the plugin fills, it never overwrites. Migrate a package by writing its config; the rest of the repo keeps running from the graph.
|
|
191
|
-
- Each package-manager member is a project through core's `discover` stage (core finds only directories holding a `vx.config`), named by its `package.json`. An Nx project no workspace package matches — the root project, or an integrated repo's `project.json` library no package glob lists (analogjs) — is made a project the same way, by its `package.json` name or else its Nx name, and its targets attach there by directory; no `vx.config` is needed. One whose name a package already holds, or whose directory is gone, has nowhere to go and is reported
|
|
191
|
+
- Each package-manager member is a project through core's `discover` stage (core finds only directories holding a `vx.config`), named by its `package.json`. An Nx project no workspace package matches — the root project, or an integrated repo's `project.json` library no package glob lists (analogjs) — is made a project the same way, by its `package.json` name or else its Nx name, and its targets attach there by directory; no `vx.config` is needed. One whose name a package already holds, or whose directory is gone, has nowhere to go and is reported by `vx lock`; run its targets with `nx`. A `^target` only such a project declares is no edge, as under Nx, and an explicit edge to one of its targets is dropped with a todo (the key misses it) rather than refusing the run. A negated output (`!{projectRoot}/dist/cache`) takes its path back from the positive ones (core's A-44). A cached target with no `outputs` takes what Nx caches for it: `options.outputPath` (each path of a list), or for `build` and `prepare` `dist/{projectRoot}` and `{projectRoot}/dist`; Nx's `{projectRoot}/build` and `{projectRoot}/public` are outputs too when git tracks nothing under them; one that holds a tracked file is a todo, since vx cleans an output before the run.
|
|
192
192
|
- Nx adds an `nx-release-publish` target (`@nx/js:release-publish`) to every project with a `package.json`; it comes along as one `nx-exec` task per package, run only when asked (`vx run nx-release-publish --filter <pkg>`), and counts in `vx info`'s task total. A migration does not write it: Nx release's publish step skips a private package and a published version and rewrites `workspace:` ranges, which no one line does, so the report has one note naming the package manager's own `publish`.
|
|
193
193
|
- `nx-exec` and `nx-env` must be on every task's PATH, which they are when `@vzn/vx-migrate` is a devDependency of the workspace (its bins land in `node_modules/.bin`); a plugin loaded by path warns once per run when they are not. Both load Nx from the workspace, so a repo run from an exported `graph` with no `node_modules/nx` warns once too.
|
|
194
194
|
- A `dependsOn` entry's `params: "forward"` (the run's arguments) and `options: "forward"` (this target's options, its configuration's merged in) hand the dependency overrides, a different command than vx's one task per target: each is one todo per task, naming no dependency so a run's warnings group them (an atomized `e2e-ci` forwards to every spec), `options` only when there are options to forward.
|
|
195
195
|
- Batch executors run one task per process; an executor that reads `context.taskGraph` under `NX_BUILDABLE_LIBRARIES_TASK_GRAPH` sees none and takes Nx's project-graph path.
|
|
196
|
-
- The mapping's gaps are the migration's gaps, reported as warnings
|
|
196
|
+
- The mapping's gaps are the migration's gaps, reported as warnings by `vx lock`, once for all the tasks that carry each one. What this machine lacks to run them (the `nx-exec` bin, `node_modules/nx`, a graph export that failed) is warned on every run too. `bunx @vzn/vx-migrate --dry --from nx` lists the same set once.
|
|
197
197
|
|
|
198
198
|
## `nx-exec` — one Nx executor, one process
|
|
199
199
|
|
|
@@ -230,7 +230,7 @@ It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works
|
|
|
230
230
|
- Native or keep, a remote cache the repo shows is declared: `turboCache()` for turbo.json's enabled `remoteCache`, a `turbo link`ed `.turbo/config.json` or a CI file (`.github/workflows/*`, `.gitlab-ci.yml`, `.circleci/config.yml`) setting `TURBO_TOKEN`; `nxCache()` for one setting `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. The report names the file that showed it. Nx Cloud (`nxCloudId`, `nxCloudAccessToken`, the `nx-cloud` runner) is named instead: vx does not speak its wire.
|
|
231
231
|
- With no `.vx/workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the remote cache above, the `@vzn/vx-lockfile` factory for the lockfile (`pnpm()`, `bun()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file it writes, or to one already in that shape; any other workspace file is the user's and left alone. Native over a file vx wrote (a keep adoption's) rewrites it: `turbo()` and `nx()` go, since the configs written replace them, and the other plugins stay.
|
|
232
232
|
- It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, the declared plugins' packages, and `@vzn/vx-migrate` for keep or when a written task runs its `nx-exec` or `nx-env` bin (`pnpm add -D -w`, `yarn add -D -W` on Yarn 1 and without `-W` on Yarn 2+, `bun add -d`, `npm install -D`). A package the root both lists and has installed at vx-migrate's own version is left alone; any other is installed at that version. Native removes a listed `@vzn/vx-migrate` when nothing written needs it (no `nx-exec` / `nx-env` task, no `turboCache()` or `nxCache()` left in the workspace file): `pnpm remove -w`, `yarn remove` (`-W` on Yarn 1), `bun remove`, `npm uninstall`. `--no-install` skips both.
|
|
233
|
-
- Native, once the files are written, it ends with `vx lock`: `.vx/projects.lock.json` is written and the graph rules are checked,
|
|
233
|
+
- Native, once the files are written, it ends with `vx lock`: `.vx/projects.lock.json` is written and the graph rules are checked, its line the report's `lock` row. A refusal prints above the report and the configs stay written (exit 0). `--no-install` skips it, since the plugins the workspace file declares are not installed yet, and `next` then starts with `vx lock`.
|
|
234
234
|
|
|
235
235
|
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()`. 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 map their scripts. Beside turbo.json, Turbo runs the tasks and Lerna only publishes: turbo.json is the source.
|
|
236
236
|
|
|
@@ -240,6 +240,8 @@ Your `package.json` scripts are never edited. `--dry` installs nothing and says
|
|
|
240
240
|
|
|
241
241
|
Every source hands a plan to core's migration seam (`applyMigration` from `@vzn/vx`), which renders, refuses to overwrite without `--force`, writes and reports. Anything a source cannot say becomes a `TODO(vx-migrate)` comment, never a silent wrong value.
|
|
242
242
|
|
|
243
|
+
The report reads like a run's footer: a blank line, the `─ vx-migrate <version> ───` rule, then one dim label per row and each fact once: `from` (the source), `ran` (each install or removal command), `tasks` (clean in green, todo in yellow), `todo`, `files`, `kept`, `replaced`, `lock`, `note`, and last, after a blank line, `next` in bold. A terminal sees the install command while it runs, on a line the report replaces; the manager's own output prints only when it fails.
|
|
244
|
+
|
|
243
245
|
### Turbo
|
|
244
246
|
|
|
245
247
|
Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package `turbo.json` `extends` overlays and each package's scripts, through the same mapper `turbo()` runs live — so the written configs say what the plugin was doing. Turbo's global fields become a generated root `vx-preset.ts` each config imports and spreads: TypeScript composition replaces global config. A config that a `tsconfig*.json` in its directory or above (below the root) takes in, where that project is composite or sets `rootDir`, declares the values it uses instead: `tsc` refuses a file outside the package (TS6059, TS6307; withastro/astro's `scripts/` and the `performance/` project its `packages/astro/tsconfig.test.json` takes in). So does a task's `env` that several configs share (`buildEnv`), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo's `//#` root tasks are written to a `vx.config.ts` at the workspace root, which makes the root a project (core's D-39), and a package task's `//#x` edge reaches it. When a `.vx/workspace.ts` already exists without one, the report names the `@vzn/vx-lockfile` plugin for the repo's lockfile: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.
|
|
@@ -255,7 +257,7 @@ Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package
|
|
|
255
257
|
|
|
256
258
|
### Nx
|
|
257
259
|
|
|
258
|
-
Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does; with no `node_modules/.bin/nx`, as in a fresh clone, it stops and names the repo manager's install), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `.vx/workspace.ts` still holds, as the Turbo migration does: the `nx()` a keep adoption 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`), so the package manager installs it and a lockfile plugin names it; 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 (`run.concurrency`, `run.affectedBase`, `cache.retention.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.
|
|
260
|
+
Reads the **resolved** project graph only (`.nx/workspace-data/project-graph.json` when exported, else the one the workspace's own `nx graph` exports into a temp file, as `nx()` does; with no `node_modules/.bin/nx`, as in a fresh clone, it stops and names the repo manager's install), through the same mapper `nx()` runs live, executors aside (below). Targets Nx plugins infer at runtime are frozen as the snapshot saw them. `nx:run-commands` is the one shell line `nx()` runs (see [`nx:run-commands`](#nxrun-commands) above: where, parallel or in order, forwarded arguments, `env`, `readyWhen`) — storybook's `compile` is `cd ../../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`; a plain `command` is that shorthand; `nx:run-script` is the package's script body with its `pre<name>` / `post<name>` hooks folded in (or `yarn run <name>` when the body calls yarn's `run` builtin; an empty script is the placeholder with a todo), `nx:noop` is a group task; Nx 15–16's `@nrwl/workspace:run-commands` and `run-script` (and their `@nx/workspace:` names) are the `nx:` executors they re-exported; **an executor target is its `nx-exec` line**, as `nx()` runs it: the migrator translates no executor, so Nx and `@vzn/vx-migrate` stay installed until each such line is rewritten as the command it runs (a server executor Nx knows, `@nx/js:node` or a dev server, is still a persistent task). The report also says what `.vx/workspace.ts` still holds, as the Turbo migration does: the `nx()` a keep adoption 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`), so the package manager installs it and a lockfile plugin names it; the migrator never edits the root manifest. A repo with no `package.json` at all (Nx's `nx init` outside JS: Nx pinned in `.nx/installation`, projects as `project.json`) is migrated too, natively only: the root is the directory holding `nx.json`, the graph is exported by `.nx/installation`'s `nx`, each config writes the project's Nx `name`, every edge is the explicit `project#target` Nx's graph draws (a `^` edge needs a lockfile plugin, which has no lockfile to read), and `.vx/workspace.ts` declares no plugin, so nothing is installed and core runs the configs alone; `--keep` refuses, as `nx()` would have no `package.json` to install into, and an executor target's `nx-exec` line is a note to rewrite as a command. 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 (`run.concurrency`, `run.affectedBase`, `cache.retention.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.
|
|
259
261
|
|
|
260
262
|
### Vite Task
|
|
261
263
|
|
|
@@ -297,7 +299,7 @@ package.json and re-run") rather than "no scripts" or "run vx init". A
|
|
|
297
299
|
workspace whose globs match no `package.json` gets a note naming them
|
|
298
300
|
(M-42); a nameless member, or two sharing a name, is matched and
|
|
299
301
|
named on its own line instead (M-46). When such skipped scripts are all there
|
|
300
|
-
is, the report
|
|
302
|
+
is, the report's `tasks` row reads `none: no package.json script became a task` rather than
|
|
301
303
|
saying there are none (M-59). When `pnpm-workspace.yaml` lists the
|
|
302
304
|
members and the root `package.json`'s `workspaces` lists other globs,
|
|
303
305
|
the note names the unread list (M-51): pnpm reads only the yaml, while
|
|
@@ -386,7 +388,7 @@ config every later command refuses, and a `__proto__` script is written
|
|
|
386
388
|
as a computed key, since a literal `__proto__:` sets the prototype. An
|
|
387
389
|
existing `.vx/workspace.*` in any extension the loader reads (`.mts`
|
|
388
390
|
included) is kept, and `--force` REPLACES a package's config of another
|
|
389
|
-
extension (`replaced
|
|
391
|
+
extension (`replaced` in the report) rather than writing a second one
|
|
390
392
|
the loader would choose between by its order (item 1033). Without
|
|
391
393
|
`--force`, it never overwrites: a package that already has a vx
|
|
392
394
|
config keeps it, untouched, the rest get theirs, and the report lists
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.639",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo, Nx or package.json scripts: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@vzn/vx": "^0.0.
|
|
43
|
+
"@vzn/vx": "^0.0.639"
|
|
44
44
|
},
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
package/src/adopt.ts
CHANGED
|
@@ -168,7 +168,6 @@ export function install(
|
|
|
168
168
|
pm === 'pnpm'
|
|
169
169
|
? `; if pnpm refused a version published within its minimumReleaseAge, list ${packages.join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
|
|
170
170
|
: '',
|
|
171
|
-
' (what the written files import)',
|
|
172
171
|
)
|
|
173
172
|
}
|
|
174
173
|
|
|
@@ -205,10 +204,12 @@ async function runManager(
|
|
|
205
204
|
argv: string[],
|
|
206
205
|
what: string,
|
|
207
206
|
ranHint = '',
|
|
208
|
-
why = '',
|
|
209
207
|
): Promise<string> {
|
|
210
208
|
const line = argv.join(' ')
|
|
211
|
-
|
|
209
|
+
// The report's `install` row says what ran; a terminal sees it while it
|
|
210
|
+
// runs, on a line the report then takes the place of.
|
|
211
|
+
const live = process.stdout.isTTY === true
|
|
212
|
+
if (live) process.stdout.write(`\n ${line.slice(0, (process.stdout.columns ?? 80) - 4)} …`)
|
|
212
213
|
// The manager's own output (progress, deprecations, audit, postinstalls)
|
|
213
214
|
// is shown only when it fails: mid-init it buried the report (Growth's
|
|
214
215
|
// first-run funnel F3, 2026-10-13).
|
|
@@ -237,6 +238,7 @@ async function runManager(
|
|
|
237
238
|
failed = `: ${(e2 as Error).message}`
|
|
238
239
|
}
|
|
239
240
|
}
|
|
241
|
+
if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
|
|
240
242
|
if (code !== 0) {
|
|
241
243
|
process.stderr.write(said)
|
|
242
244
|
throw new UserError(
|
package/src/adoption-plugin.ts
CHANGED
|
@@ -29,7 +29,7 @@ async function spare(
|
|
|
29
29
|
if (ids === undefined) todos.set(todo, (ids = []))
|
|
30
30
|
ids.push(id)
|
|
31
31
|
}
|
|
32
|
-
return { byName: mapping.byName, gaps: {
|
|
32
|
+
return { byName: mapping.byName, gaps: { ...mapping.gaps, todos } }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -97,7 +97,7 @@ export function adoptionPlugin(
|
|
|
97
97
|
const mapped = await mapping!
|
|
98
98
|
if (!warned) {
|
|
99
99
|
warned = true
|
|
100
|
-
warnGaps(ctx.warn, plugin.name, mapped.gaps)
|
|
100
|
+
warnGaps(ctx.warn, plugin.name, mapped.gaps, ctx.lock)
|
|
101
101
|
}
|
|
102
102
|
const project = mapped.byName.get(ctx.name)
|
|
103
103
|
if (project === undefined) return
|
package/src/mapping-cache.ts
CHANGED
|
@@ -61,6 +61,7 @@ interface Held {
|
|
|
61
61
|
][]
|
|
62
62
|
readonly notes: string[]
|
|
63
63
|
readonly todos: [string, string[]][]
|
|
64
|
+
readonly live: string[]
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** `map()`, or the mapping it returned last under the same `parts`. */
|
|
@@ -78,7 +79,7 @@ export async function cachedMapping(
|
|
|
78
79
|
if (held?.key === key) {
|
|
79
80
|
return {
|
|
80
81
|
byName: new Map(held.byName),
|
|
81
|
-
gaps: { notes: held.notes, todos: new Map(held.todos) },
|
|
82
|
+
gaps: { notes: held.notes, todos: new Map(held.todos), live: held.live },
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
const mapping = await map()
|
|
@@ -93,6 +94,7 @@ export async function cachedMapping(
|
|
|
93
94
|
]),
|
|
94
95
|
notes: [...mapping.gaps.notes],
|
|
95
96
|
todos: [...mapping.gaps.todos].map(([todo, ids]) => [todo, [...ids]]),
|
|
97
|
+
live: [...mapping.gaps.live],
|
|
96
98
|
}
|
|
97
99
|
// Best-effort, and whole or absent: a reader never sees half a file, and
|
|
98
100
|
// a cache dir it cannot write costs the next run a mapping, not the run.
|
package/src/migrate-nx.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { stat } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { type MigrationFormat, type MigrationPlan } from '@vzn/vx'
|
|
6
|
+
import { type GeneratedProject, type MigrationFormat, type MigrationPlan } from '@vzn/vx'
|
|
7
7
|
import type { PackageMeta } from './packages.js'
|
|
8
8
|
import { mapNxWorkspace, nxSizeText, parseNxGraph, readNxJson } from './nx/nx-map.js'
|
|
9
9
|
import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
|
|
@@ -23,6 +23,8 @@ export async function migrateNx(
|
|
|
23
23
|
snapshot = path.join(root, NX_GRAPH_REL),
|
|
24
24
|
/** This run writes `.vx/workspace.ts`, with nx.json's run settings as fields. */
|
|
25
25
|
writesWorkspace = false,
|
|
26
|
+
/** No package.json anywhere: each config names its project and holds every edge. */
|
|
27
|
+
bare = false,
|
|
26
28
|
): Promise<MigrationPlan> {
|
|
27
29
|
const graph = parseNxGraph(await Bun.file(snapshot).text(), path.relative(root, snapshot))
|
|
28
30
|
const tracked = await trackedFiles(root)
|
|
@@ -48,14 +50,20 @@ export async function migrateNx(
|
|
|
48
50
|
runner: 'nx',
|
|
49
51
|
keys: (lock) => `Nx keys each project on the npm packages it depends on in ${lock}`,
|
|
50
52
|
})
|
|
51
|
-
const unlisted =
|
|
53
|
+
const unlisted = bare
|
|
54
|
+
? { manifests: [], notes: bareNotes(mapped.projects) }
|
|
55
|
+
: await unlistedProjects(root, metas, mapped.projects)
|
|
56
|
+
if (bare) for (const p of mapped.projects) explicitOnly(p)
|
|
52
57
|
return {
|
|
53
58
|
headerNotes: [
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
|
|
59
|
+
bare
|
|
60
|
+
? 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
|
|
61
|
+
'are frozen as static config; `nx:run-commands` targets are their shell lines'
|
|
62
|
+
: 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
|
|
63
|
+
'are frozen as static config; `nx:run-commands` targets are their shell lines, and ' +
|
|
64
|
+
'every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate ' +
|
|
65
|
+
'installed until those targets are rewritten as commands); targets with `.env` files ' +
|
|
66
|
+
'run through `nx-env`',
|
|
59
67
|
...adopted,
|
|
60
68
|
],
|
|
61
69
|
projects: mapped.projects.map((p) =>
|
|
@@ -74,6 +82,38 @@ export async function migrateNx(
|
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A project with no manifest is named by its path unless its config names
|
|
87
|
+
* it, and a `^` edge follows the lockfile plugin's package edges, which a
|
|
88
|
+
* repo with no package.json has none of: the config keeps Nx's name, and
|
|
89
|
+
* the explicit edges the mapper drew from Nx's graph are every edge.
|
|
90
|
+
*/
|
|
91
|
+
function explicitOnly(p: GeneratedProject): void {
|
|
92
|
+
p.writeName = true
|
|
93
|
+
for (const t of p.tasks) {
|
|
94
|
+
const deps = t.task?.['dependsOn']
|
|
95
|
+
if (!Array.isArray(deps)) continue
|
|
96
|
+
const kept = deps.filter((d) => typeof d !== 'string' || !d.startsWith('^'))
|
|
97
|
+
// A group keeps its list, as `dropUnheldDeps` keeps one.
|
|
98
|
+
if (kept.length > 0 || t.task!['exec'] === undefined) t.task!['dependsOn'] = kept
|
|
99
|
+
else delete t.task!['dependsOn']
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `nx-exec` and `nx-env` are this package's bins, and a repo with no package.json cannot install it. */
|
|
104
|
+
function bareNotes(projects: readonly GeneratedProject[]): string[] {
|
|
105
|
+
const ids = projects.flatMap((p) =>
|
|
106
|
+
p.tasks.flatMap((t) => {
|
|
107
|
+
const cmd = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
|
|
108
|
+
return typeof cmd === 'string' && /^nx-(exec|env) /.test(cmd) ? [`${p.name}#${t.name}`] : []
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
if (ids.length === 0) return []
|
|
112
|
+
return [
|
|
113
|
+
`${ids.join(', ')} run${ids.length === 1 ? 's' : ''} through nx-exec or nx-env, @vzn/vx-migrate's bins, which a repo with no package.json cannot install: rewrite ${ids.length === 1 ? 'it' : 'them'} as commands`,
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
|
|
77
117
|
/**
|
|
78
118
|
* Nx projects no workspace glob lists (an integrated repo's `project.json`
|
|
79
119
|
* libraries): core finds a project only by a listed package.json, so a
|
package/src/migrate.ts
CHANGED
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
applyMigration,
|
|
21
21
|
findWorkspaceRoot,
|
|
22
22
|
isUserError,
|
|
23
|
+
VERSION,
|
|
23
24
|
type MigrationFormat,
|
|
24
25
|
type MigrationPlan,
|
|
25
26
|
UserError,
|
|
27
|
+
ui,
|
|
26
28
|
} from '@vzn/vx'
|
|
27
29
|
import { migrateScripts } from './migrate-scripts.js'
|
|
28
30
|
import type { LoadReads } from './package-fs.js'
|
|
@@ -39,7 +41,7 @@ import {
|
|
|
39
41
|
import { relPosix } from './paths.js'
|
|
40
42
|
import { REMOTE_CACHE, remoteCacheNote } from './remote-cache.js'
|
|
41
43
|
import { migrateNx, NX_GRAPH_REL, nxWorkspaceFields } from './migrate-nx.js'
|
|
42
|
-
import { exportGraph } from './nx/export-graph.js'
|
|
44
|
+
import { exportGraph, nxBin } from './nx/export-graph.js'
|
|
43
45
|
import { readNxJson } from './nx/nx-map.js'
|
|
44
46
|
import {
|
|
45
47
|
type AdoptionMode,
|
|
@@ -148,12 +150,18 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
148
150
|
return 1
|
|
149
151
|
}
|
|
150
152
|
const reads: LoadReads = new Map()
|
|
153
|
+
// An Nx repo with no package.json (Nx pinned in `.nx/installation`):
|
|
154
|
+
// nothing is installable, so its configs run on core alone.
|
|
155
|
+
let bare = false
|
|
151
156
|
const root = await migrationRoot(process.cwd(), reads).catch(async (err: unknown) => {
|
|
152
|
-
|
|
157
|
+
const nx = isUserError(err) ? nxRootWithoutManifest(process.cwd()) : undefined
|
|
158
|
+
if (nx === undefined) throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
|
|
159
|
+
bare = true
|
|
160
|
+
return nx
|
|
153
161
|
})
|
|
154
|
-
const workspace = await loadPackageWorkspace(root, reads)
|
|
162
|
+
const workspace = bare ? { root, packageGlobs: [] } : await loadPackageWorkspace(root, reads)
|
|
155
163
|
const nameless: string[] = []
|
|
156
|
-
const metas = await discoverPackages(workspace, nameless)
|
|
164
|
+
const metas = bare ? [] : await discoverPackages(workspace, nameless)
|
|
157
165
|
|
|
158
166
|
const turboFile = await turboConfigFile(root)
|
|
159
167
|
const hasTurbo = turboFile !== null
|
|
@@ -214,13 +222,24 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
214
222
|
metas.some((m) => m.configPath !== null) &&
|
|
215
223
|
!readFileSync(path.join(root, existing), 'utf8').includes(`${runner}(`)
|
|
216
224
|
) {
|
|
217
|
-
|
|
218
|
-
|
|
225
|
+
ui.print(
|
|
226
|
+
'out',
|
|
227
|
+
ui.report(`vx-migrate ${VERSION}`, [
|
|
228
|
+
['kept', `${existing} and the projects' vx.config files`],
|
|
229
|
+
['note', `vx is already set up here; --force regenerates them from ${runnerFile}`],
|
|
230
|
+
null,
|
|
231
|
+
['next', adoptionNext(root, runner, runnerFile, ['@vzn/vx'])],
|
|
232
|
+
]),
|
|
219
233
|
)
|
|
220
234
|
return 0
|
|
221
235
|
}
|
|
236
|
+
if (bare && parsed.mode === 'keep') {
|
|
237
|
+
throw new UserError(
|
|
238
|
+
'--keep: nx() is a plugin to install, and this Nx repo has no package.json to install it in; drop --keep to write vx.config files',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
222
241
|
const mode =
|
|
223
|
-
runner === 'vite-task'
|
|
242
|
+
runner === 'vite-task' || bare
|
|
224
243
|
? 'native'
|
|
225
244
|
: (parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry)))
|
|
226
245
|
if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, runnerFile, parsed)
|
|
@@ -248,14 +267,14 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
248
267
|
} else if (runner === 'nx') {
|
|
249
268
|
if (hasGraph) {
|
|
250
269
|
source = NX_GRAPH_REL
|
|
251
|
-
plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
|
|
270
|
+
plan = await migrateNx(root, metas, format, undefined, writesWorkspace, bare)
|
|
252
271
|
} else {
|
|
253
272
|
// Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
|
|
254
273
|
// when exported. The workspace's own nx exports it, as `nx()` does, into
|
|
255
274
|
// a temp file; the user ran that step by hand until 2026-10-01.
|
|
256
275
|
// A fresh clone: the export's own reason names the plugin's `graph`
|
|
257
276
|
// option, which the CLI does not take.
|
|
258
|
-
if (
|
|
277
|
+
if (nxBin(root) === undefined) {
|
|
259
278
|
throw new UserError(
|
|
260
279
|
`nx is not installed here (no node_modules/.bin/nx), and vx-migrate reads the graph it exports — ` +
|
|
261
280
|
`run \`${packageManagerOf(root, process.env['npm_config_user_agent'])} install\`, then vx-migrate again`,
|
|
@@ -272,7 +291,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
272
291
|
)
|
|
273
292
|
}
|
|
274
293
|
source = 'nx graph'
|
|
275
|
-
plan = await migrateNx(root, metas, format, snapshot, writesWorkspace)
|
|
294
|
+
plan = await migrateNx(root, metas, format, snapshot, writesWorkspace, bare)
|
|
276
295
|
} finally {
|
|
277
296
|
await rm(tmp, { recursive: true, force: true })
|
|
278
297
|
}
|
|
@@ -294,14 +313,15 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
294
313
|
) ||
|
|
295
314
|
plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
|
|
296
315
|
const kept = (adopted ?? []).filter((p) => !ADOPTION_PLUGINS.has(`${p.pkg}#${p.factory}`))
|
|
297
|
-
const plugins =
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
316
|
+
const plugins =
|
|
317
|
+
writesWorkspace && !bare
|
|
318
|
+
? [
|
|
319
|
+
...kept,
|
|
320
|
+
...workspacePlugins(root, runner === 'vite-task' ? undefined : runner).filter(
|
|
321
|
+
(p) => !kept.some((k) => k.factory === p.factory),
|
|
322
|
+
),
|
|
323
|
+
]
|
|
324
|
+
: []
|
|
305
325
|
let workspaceText: string | undefined
|
|
306
326
|
if (writesWorkspace) {
|
|
307
327
|
const fields =
|
|
@@ -340,33 +360,34 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
340
360
|
runner === 'vite-task'
|
|
341
361
|
? undefined
|
|
342
362
|
: remoteCacheNote(root, runner, finalWorkspace.includes(`${REMOTE_CACHE[runner]}(`))
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
+
const steps: Step[] =
|
|
364
|
+
refused || bare
|
|
365
|
+
? []
|
|
366
|
+
: [
|
|
367
|
+
...(existingWorkspace !== undefined && workspaceText !== undefined
|
|
368
|
+
? await rewriteWorkspace(root, existingWorkspace, workspaceText, adopted!, parsed.dry)
|
|
369
|
+
: []),
|
|
370
|
+
...(await prepareRepo(
|
|
371
|
+
root,
|
|
372
|
+
[
|
|
373
|
+
...new Set([
|
|
374
|
+
'@vzn/vx',
|
|
375
|
+
...(needsSelf ? ['@vzn/vx-migrate'] : []),
|
|
376
|
+
...plugins.map((p) => p.pkg),
|
|
377
|
+
]),
|
|
378
|
+
],
|
|
379
|
+
parsed,
|
|
380
|
+
needsSelf ? [] : ['@vzn/vx-migrate'],
|
|
381
|
+
)),
|
|
382
|
+
]
|
|
363
383
|
return applyMigration({
|
|
364
384
|
root,
|
|
365
385
|
metas,
|
|
366
386
|
plan,
|
|
367
387
|
source,
|
|
368
388
|
verb: 'vx-migrate',
|
|
369
|
-
|
|
389
|
+
steps,
|
|
390
|
+
notes: refused || bare || cacheNote === undefined ? [] : [cacheNote],
|
|
370
391
|
dry: parsed.dry,
|
|
371
392
|
json: parsed.json === true,
|
|
372
393
|
force: parsed.force,
|
|
@@ -380,13 +401,24 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
380
401
|
* Core's `vx lock`, run where the configs were written: the step `vx init`
|
|
381
402
|
* ends with, through the core this repo resolves.
|
|
382
403
|
*/
|
|
383
|
-
async function lockWorkspace(root: string): Promise<number> {
|
|
384
|
-
|
|
404
|
+
async function lockWorkspace(root: string): Promise<{ code: number; said: string }> {
|
|
405
|
+
const p = Bun.spawn([process.execPath, '--no-install', coreBin(root), 'lock'], {
|
|
385
406
|
cwd: root,
|
|
386
|
-
stdio: ['ignore', '
|
|
387
|
-
})
|
|
407
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
408
|
+
})
|
|
409
|
+
const [out, err, code] = await Promise.all([
|
|
410
|
+
new Response(p.stdout).text(),
|
|
411
|
+
new Response(p.stderr).text(),
|
|
412
|
+
p.exited,
|
|
413
|
+
])
|
|
414
|
+
// A success's warnings print above the report; a refusal is said whole.
|
|
415
|
+
if (code === 0) process.stderr.write(err)
|
|
416
|
+
return { code, said: code === 0 ? out : out + err }
|
|
388
417
|
}
|
|
389
418
|
|
|
419
|
+
/** A report row: what the tool did, e.g. `['ran', 'bun add -d …']`. */
|
|
420
|
+
type Step = readonly [label: string, value: string]
|
|
421
|
+
|
|
390
422
|
/** The `vx` entry of the core this repo resolves. */
|
|
391
423
|
function coreBin(root: string): string {
|
|
392
424
|
// A resolve that misses from a directory with no node_modules is an
|
|
@@ -427,7 +459,7 @@ const ADOPTION_PLUGINS = new Set(['@vzn/vx-migrate#turbo', '@vzn/vx-migrate#nx']
|
|
|
427
459
|
|
|
428
460
|
/**
|
|
429
461
|
* A `--keep` adoption's workspace file, written over for a native one.
|
|
430
|
-
* Returns the report's
|
|
462
|
+
* Returns the report's row; writes nothing under `--dry`.
|
|
431
463
|
*/
|
|
432
464
|
async function rewriteWorkspace(
|
|
433
465
|
root: string,
|
|
@@ -435,41 +467,41 @@ async function rewriteWorkspace(
|
|
|
435
467
|
text: string,
|
|
436
468
|
before: readonly WorkspacePlugin[],
|
|
437
469
|
dry: boolean,
|
|
438
|
-
): Promise<
|
|
470
|
+
): Promise<Step[]> {
|
|
439
471
|
if (readFileSync(path.join(root, file), 'utf8') === text) return []
|
|
440
472
|
const dropped = before.filter((p) => ADOPTION_PLUGINS.has(`${p.pkg}#${p.factory}`))
|
|
441
473
|
const names = dropped.map((p) => `${p.factory}()`).join(', ')
|
|
442
474
|
if (dry)
|
|
443
|
-
return [`would ${dropped.length > 0 ? `drop ${names} from` : 'rewrite'} ${file}
|
|
475
|
+
return [['config', `would ${dropped.length > 0 ? `drop ${names} from` : 'rewrite'} ${file}`]]
|
|
444
476
|
await Bun.write(path.join(root, file), text)
|
|
445
|
-
return [`${dropped.length > 0 ? `dropped ${names} from` : 'rewrote'} ${file}`]
|
|
477
|
+
return [['config', `${dropped.length > 0 ? `dropped ${names} from` : 'rewrote'} ${file}`]]
|
|
446
478
|
}
|
|
447
479
|
|
|
448
480
|
/**
|
|
449
481
|
* Install what the written files import and remove what they no longer
|
|
450
|
-
* do. Returns the report's
|
|
482
|
+
* do. Returns the report's rows; changes nothing under `--dry`.
|
|
451
483
|
*/
|
|
452
484
|
async function prepareRepo(
|
|
453
485
|
root: string,
|
|
454
486
|
wanted: readonly string[],
|
|
455
487
|
args: MigrateArgs,
|
|
456
488
|
unwanted: readonly string[] = [],
|
|
457
|
-
): Promise<
|
|
489
|
+
): Promise<Step[]> {
|
|
458
490
|
if (args.noInstall === true) return []
|
|
459
|
-
const
|
|
491
|
+
const steps: Step[] = []
|
|
460
492
|
const version = ownVersion()
|
|
461
493
|
const missing = missingPackages(root, wanted, version)
|
|
462
494
|
if (missing.length > 0) {
|
|
463
|
-
|
|
464
|
-
|
|
495
|
+
steps.push(
|
|
496
|
+
args.dry ? ['install', missing.join(' ')] : ['ran', await install(root, missing, version)],
|
|
497
|
+
)
|
|
465
498
|
}
|
|
466
499
|
// Last: the package removed may be the one running this.
|
|
467
500
|
const listed = listedPackages(root, unwanted)
|
|
468
501
|
if (listed.length > 0) {
|
|
469
|
-
|
|
470
|
-
else notes.push(`removed ${listed.join(' ')} (${await uninstall(root, listed)})`)
|
|
502
|
+
steps.push(args.dry ? ['remove', listed.join(' ')] : ['ran', await uninstall(root, listed)])
|
|
471
503
|
}
|
|
472
|
-
return
|
|
504
|
+
return steps
|
|
473
505
|
}
|
|
474
506
|
|
|
475
507
|
/**
|
|
@@ -502,13 +534,13 @@ async function keep(
|
|
|
502
534
|
const extended = before === undefined ? null : extendWorkspaceFile(before, all)
|
|
503
535
|
const text = before === undefined ? renderWorkspaceFile(all, format) : (extended ?? before)
|
|
504
536
|
const declares = before === undefined || extended !== null
|
|
505
|
-
|
|
537
|
+
const steps = await prepareRepo(
|
|
506
538
|
root,
|
|
507
539
|
[...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])])],
|
|
508
540
|
args,
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
|
|
541
|
+
)
|
|
542
|
+
if (args.json)
|
|
543
|
+
for (const [label, value] of steps) process.stderr.write(`vx-migrate: ${label}: ${value}\n`)
|
|
512
544
|
const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
|
|
513
545
|
if (!args.dry) {
|
|
514
546
|
if (before === undefined) {
|
|
@@ -524,14 +556,15 @@ async function keep(
|
|
|
524
556
|
? undefined
|
|
525
557
|
: remoteCacheNote(root, runner, text.includes(`${cache}(`))
|
|
526
558
|
const added = before === undefined ? [] : undeclared(before, all).map((p) => `${p.factory}()`)
|
|
527
|
-
const wrote =
|
|
559
|
+
const wrote: Step =
|
|
528
560
|
before === undefined
|
|
529
|
-
?
|
|
530
|
-
? `would write ${name} (dry run, nothing written).`
|
|
531
|
-
: `wrote ${name}.`
|
|
561
|
+
? ['files', [name, ...ignore].join('\n')]
|
|
532
562
|
: text === before
|
|
533
|
-
? `${existing}
|
|
534
|
-
:
|
|
563
|
+
? ['kept', `${existing} (declares ${runner}())`]
|
|
564
|
+
: [
|
|
565
|
+
'config',
|
|
566
|
+
`${args.dry ? 'would declare' : 'declared'} ${added.join(', ')} in ${existing}`,
|
|
567
|
+
]
|
|
535
568
|
const next = adoptionNext(root, runner, source, [
|
|
536
569
|
...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])]),
|
|
537
570
|
])
|
|
@@ -556,17 +589,40 @@ async function keep(
|
|
|
556
589
|
)
|
|
557
590
|
return 0
|
|
558
591
|
}
|
|
559
|
-
process.stdout.write(
|
|
560
|
-
`${args.dry && before === undefined ? `── ${name} ──\n${text}\n` : ''}` +
|
|
561
|
-
`vx-migrate: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config.\n` +
|
|
562
|
-
`${wrote}\n${cacheLine === undefined ? '' : `${cacheLine}\n`}\n`,
|
|
563
|
-
)
|
|
592
|
+
if (args.dry && before === undefined) process.stdout.write(`── ${name} ──\n${text}\n`)
|
|
564
593
|
// A run reads only the lock; with the plugins installed it is written
|
|
565
594
|
// now, else `next` names `vx lock` after the install.
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
595
|
+
const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
|
|
596
|
+
if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
|
|
597
|
+
const rows: ui.FieldRow[] = [
|
|
598
|
+
['from', `${source}, run by ${runner}() from @vzn/vx-migrate`],
|
|
599
|
+
...(args.dry ? [['dry run', 'nothing written'] as const] : []),
|
|
600
|
+
...steps,
|
|
601
|
+
wrote,
|
|
602
|
+
...(lock === undefined ? [] : [lockRow(lock)]),
|
|
603
|
+
[
|
|
604
|
+
'note',
|
|
605
|
+
'a temporary start: vx init --native writes native config',
|
|
606
|
+
...(cacheLine === undefined ? [] : [cacheLine]),
|
|
607
|
+
],
|
|
608
|
+
null,
|
|
609
|
+
[
|
|
610
|
+
'next',
|
|
611
|
+
lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
|
|
612
|
+
],
|
|
613
|
+
]
|
|
614
|
+
ui.print('out', ui.report(`vx-migrate ${VERSION}`, rows))
|
|
615
|
+
return lock?.code ?? 0
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** `vx lock`'s outcome as the report's row. */
|
|
619
|
+
function lockRow(lock: { code: number; said: string }): ui.FieldRow {
|
|
620
|
+
return [
|
|
621
|
+
'lock',
|
|
622
|
+
lock.code === 0
|
|
623
|
+
? lock.said.trim().replace(/^✓ vx lock\s+/, '')
|
|
624
|
+
: ui.tone('error', 'not written'),
|
|
625
|
+
]
|
|
570
626
|
}
|
|
571
627
|
|
|
572
628
|
/** `.vx/.gitignore`'s text, as core writes it: the machine-local cache stays out of git. */
|
|
@@ -670,12 +726,8 @@ async function scripts(
|
|
|
670
726
|
plan,
|
|
671
727
|
source: 'package.json scripts',
|
|
672
728
|
verb: 'vx-migrate',
|
|
673
|
-
notes: [
|
|
674
|
-
|
|
675
|
-
...(await unreadWorkspaces(root, workspace.packageGlobs)),
|
|
676
|
-
...unmapped,
|
|
677
|
-
...installed,
|
|
678
|
-
],
|
|
729
|
+
notes: [...noMembers, ...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
|
|
730
|
+
steps: installed,
|
|
679
731
|
unmapped: unmapped.length > 0,
|
|
680
732
|
...(unreached.length > 0 ? { emptyReason: unreachedHint(unreached, root) } : {}),
|
|
681
733
|
dry: args.dry,
|
|
@@ -776,6 +828,18 @@ async function migrationRoot(cwd: string, reads: LoadReads): Promise<string> {
|
|
|
776
828
|
return marked
|
|
777
829
|
}
|
|
778
830
|
|
|
831
|
+
/**
|
|
832
|
+
* The nearest directory up to the git top-level holding nx.json, for a
|
|
833
|
+
* repo with no package.json: Nx's `nx init` outside JS pins Nx in
|
|
834
|
+
* `.nx/installation` and finds projects by `project.json`.
|
|
835
|
+
*/
|
|
836
|
+
function nxRootWithoutManifest(cwd: string): string | undefined {
|
|
837
|
+
for (let d = cwd; ; d = path.dirname(d)) {
|
|
838
|
+
if (existsSync(path.join(d, 'nx.json'))) return d
|
|
839
|
+
if (existsSync(path.join(d, '.git')) || path.dirname(d) === d) return undefined
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
779
843
|
/**
|
|
780
844
|
* Lerna 6+ runs `lerna run` on Nx's task runner over the graph `nx graph`
|
|
781
845
|
* exports, nx.json or not, unless lerna.json says `useNx: false`; Lerna 5
|
package/src/nx/export-graph.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The resolved Nx graph, exported by the workspace's own nx: `nx()` keys and
|
|
2
2
|
// keeps it, `vx-migrate --from nx` maps it once.
|
|
3
3
|
|
|
4
|
-
import { readFileSync, realpathSync } from 'node:fs'
|
|
4
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
5
5
|
import { mkdir } from 'node:fs/promises'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
|
|
@@ -11,10 +11,9 @@ import path from 'node:path'
|
|
|
11
11
|
* daemon up the export is served from memory, without it Nx computes.
|
|
12
12
|
*/
|
|
13
13
|
export async function exportGraph(root: string, snapshot: string): Promise<string | null> {
|
|
14
|
-
const
|
|
15
|
-
const bin = (await Bun.file(linked).exists()) ? linked : lernasNx(root)
|
|
14
|
+
const bin = nxBin(root)
|
|
16
15
|
if (bin === undefined) {
|
|
17
|
-
return `no
|
|
16
|
+
return `no node_modules/.bin/nx — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
|
|
18
17
|
}
|
|
19
18
|
// `discover` runs before the run opens (and makes) the cache dir.
|
|
20
19
|
await mkdir(path.dirname(snapshot), { recursive: true })
|
|
@@ -30,6 +29,18 @@ export async function exportGraph(root: string, snapshot: string): Promise<strin
|
|
|
30
29
|
return `nx graph --file exited ${code}${tail ? `: ${tail}` : ''}`
|
|
31
30
|
}
|
|
32
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The workspace's own nx: the linked bin, else where Nx's wrapper installs
|
|
34
|
+
* it in a repo with no package.json (`.nx/installation`), else Lerna's.
|
|
35
|
+
*/
|
|
36
|
+
export function nxBin(root: string): string | undefined {
|
|
37
|
+
for (const dir of [root, path.join(root, '.nx', 'installation')]) {
|
|
38
|
+
const bin = path.join(dir, 'node_modules', '.bin', 'nx')
|
|
39
|
+
if (existsSync(bin)) return bin
|
|
40
|
+
}
|
|
41
|
+
return lernasNx(root)
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* The nx Lerna 6+ runs on, from Lerna's own dependencies: pnpm links only
|
|
35
46
|
* a root's direct dependencies into `node_modules/.bin`, so a Lerna repo
|
package/src/nx/index.ts
CHANGED
|
@@ -404,7 +404,8 @@ async function index(
|
|
|
404
404
|
trackedFiles: () => Promise<readonly string[] | null>,
|
|
405
405
|
executors: NxExecutors | undefined,
|
|
406
406
|
): Promise<AdoptionMapping> {
|
|
407
|
-
const notes = [
|
|
407
|
+
const notes: string[] = []
|
|
408
|
+
const live = [...loadNotes]
|
|
408
409
|
const tracked = await trackedFiles()
|
|
409
410
|
const configs = new Map(
|
|
410
411
|
metas.flatMap((m) =>
|
|
@@ -458,7 +459,7 @@ async function index(
|
|
|
458
459
|
if (!uses(bin)) continue
|
|
459
460
|
loadsNx = true
|
|
460
461
|
if (!(await Bun.file(path.join(root, 'node_modules', '.bin', bin)).exists())) {
|
|
461
|
-
|
|
462
|
+
live.push(
|
|
462
463
|
`${what} through \`${bin}\`, which is not in node_modules/.bin — add ` +
|
|
463
464
|
"@vzn/vx-migrate to the workspace's devDependencies so its bin is on every task's PATH",
|
|
464
465
|
)
|
|
@@ -470,7 +471,7 @@ async function index(
|
|
|
470
471
|
loadsNx &&
|
|
471
472
|
!(await Bun.file(path.join(root, 'node_modules', 'nx', 'package.json')).exists())
|
|
472
473
|
) {
|
|
473
|
-
|
|
474
|
+
live.push(
|
|
474
475
|
'nx-exec and nx-env load Nx from the workspace, and node_modules/nx is not there — ' +
|
|
475
476
|
'install nx, or those tasks fail',
|
|
476
477
|
)
|
|
@@ -480,6 +481,7 @@ async function index(
|
|
|
480
481
|
gaps: collectGaps(
|
|
481
482
|
mapped.projects.filter((p) => byName.has(p.name)),
|
|
482
483
|
notes,
|
|
484
|
+
live,
|
|
483
485
|
),
|
|
484
486
|
}
|
|
485
487
|
}
|
package/src/plugin-gaps.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// What a mapping could not say, reported once
|
|
2
|
-
//
|
|
1
|
+
// What a mapping could not say, reported once by `vx lock`, the pass that
|
|
2
|
+
// checks configs (a run checks nothing, M 2026-10-15). What this machine
|
|
3
|
+
// lacks to run the mapping (a bin, a graph export) is said on runs too. One line per DISTINCT gap, not one per task: n8n has a
|
|
3
4
|
// `dev` and a `watch` in most of its 84 packages, and astro's `build`
|
|
4
5
|
// carries the same `!vendor/**` output in every package — the per-task
|
|
5
6
|
// form was a hundred identical lines before the first frame (2026-09-11).
|
|
@@ -15,10 +16,16 @@ export interface Gaps {
|
|
|
15
16
|
readonly notes: readonly string[]
|
|
16
17
|
/** Every task-level gap, keyed on its text, to the `pkg#task` ids that carry it. */
|
|
17
18
|
readonly todos: ReadonlyMap<string, readonly string[]>
|
|
19
|
+
/** This machine's state, not the configs': said on every run as well. */
|
|
20
|
+
readonly live: readonly string[]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** The gaps of a mapping, indexed for the one-line-per-gap report. */
|
|
21
|
-
export function collectGaps(
|
|
24
|
+
export function collectGaps(
|
|
25
|
+
projects: readonly MappedProject[],
|
|
26
|
+
notes: readonly string[],
|
|
27
|
+
live: readonly string[] = [],
|
|
28
|
+
): Gaps {
|
|
22
29
|
const todos = new Map<string, string[]>()
|
|
23
30
|
for (const project of projects) {
|
|
24
31
|
for (const t of project.tasks) {
|
|
@@ -29,10 +36,18 @@ export function collectGaps(projects: readonly MappedProject[], notes: readonly
|
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
|
-
return { notes, todos }
|
|
39
|
+
return { notes, todos, live }
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
/** At `vx lock` every gap; on a run only the live notes. */
|
|
43
|
+
export function warnGaps(
|
|
44
|
+
warn: (line: string) => void,
|
|
45
|
+
pluginName: string,
|
|
46
|
+
gaps: Gaps,
|
|
47
|
+
lock: boolean,
|
|
48
|
+
): void {
|
|
49
|
+
for (const note of gaps.live) warn(`[${pluginName}] ${note}`)
|
|
50
|
+
if (!lock) return
|
|
36
51
|
for (const note of gaps.notes) warn(`[${pluginName}] ${note}`)
|
|
37
52
|
for (const [todo, ids] of gaps.todos) {
|
|
38
53
|
if (ids.length === 1) {
|
package/src/shared-outputs.ts
CHANGED
|
@@ -171,6 +171,8 @@ interface Cached {
|
|
|
171
171
|
name: string
|
|
172
172
|
files: string[]
|
|
173
173
|
hasUpstreamEdge: boolean
|
|
174
|
+
/** Globs of a sibling's this task's `!` entries take back (`separable`). */
|
|
175
|
+
back: Set<string>
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
@@ -186,9 +188,32 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
|
186
188
|
const deps = task['dependsOn']
|
|
187
189
|
const hasUpstreamEdge =
|
|
188
190
|
Array.isArray(deps) && deps.some((d) => typeof d === 'string' && d.startsWith('^'))
|
|
189
|
-
return { index, name: t.name, files: strings, hasUpstreamEdge }
|
|
191
|
+
return { index, name: t.name, files: strings, hasUpstreamEdge, back: new Set() }
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Can the keeper's `ga` give `gb`'s tree back? Only when `ga` is a
|
|
196
|
+
* directory at any depth (`**\/dist`, `**\/dist/**`) and `gb`'s literal
|
|
197
|
+
* head names no such directory: the two meet only at a `dist` nested in
|
|
198
|
+
* `gb`'s tree, which is the sibling's. solid's build outputs `**\/dist`
|
|
199
|
+
* beside types' `types/**`; Turbo caches both, and uncaching types made
|
|
200
|
+
* vx the slower of the two.
|
|
201
|
+
*/
|
|
202
|
+
function separable(ga: string, gb: string): boolean {
|
|
203
|
+
const name = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/.exec(ga)?.[1]
|
|
204
|
+
if (name === undefined) return false
|
|
205
|
+
const head: string[] = []
|
|
206
|
+
for (const seg of gb.replace(/^(\.\/)+/, '').split('/')) {
|
|
207
|
+
if (!isLiteralPattern(seg) || seg === '') break
|
|
208
|
+
head.push(seg)
|
|
209
|
+
}
|
|
210
|
+
return head.length > 0 && !head.includes(name)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Does keeper `k`'s `ga` still reach `gb` once its `!` entries are read? */
|
|
214
|
+
const hits = (k: Cached, ga: string, gb: string): boolean =>
|
|
215
|
+
!k.back.has(gb) && outputsOverlap(ga, gb)
|
|
216
|
+
|
|
192
217
|
/**
|
|
193
218
|
* Uncache every task whose declared outputs overlap a keeper's, in place,
|
|
194
219
|
* and return the tasks. Deterministic: keepers are chosen in declaration
|
|
@@ -220,14 +245,28 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
220
245
|
const kept: Cached[] = [keeper]
|
|
221
246
|
for (const c of all) {
|
|
222
247
|
if (c === keeper) continue
|
|
223
|
-
const clash = kept.find((k) =>
|
|
224
|
-
c.files.some((gb) => k.files.some((ga) => outputsOverlap(ga, gb))),
|
|
225
|
-
)
|
|
248
|
+
const clash = kept.find((k) => c.files.some((gb) => k.files.some((ga) => hits(k, ga, gb))))
|
|
226
249
|
if (clash === undefined) {
|
|
227
250
|
kept.push(c)
|
|
228
251
|
continue
|
|
229
252
|
}
|
|
230
253
|
const t = tasks[c.index]!
|
|
254
|
+
const pairs = kept.flatMap((k) =>
|
|
255
|
+
c.files.flatMap((gb) =>
|
|
256
|
+
k.files.filter((ga) => hits(k, ga, gb)).map((ga) => ({ k, ga, gb })),
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
if (pairs.every((p) => separable(p.ga, p.gb))) {
|
|
260
|
+
for (const { k, gb } of pairs) {
|
|
261
|
+
if (k.back.has(gb)) continue
|
|
262
|
+
k.back.add(gb)
|
|
263
|
+
;(tasks[k.index]!.task!['cache'] as { outputs: { files: string[] } }).outputs.files.push(
|
|
264
|
+
`!${gb}`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
kept.push(c)
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
231
270
|
// tsc's incremental state is no product: create-t3-turbo's build and
|
|
232
271
|
// typecheck both output `.cache/tsbuildinfo.json`, and typecheck ran
|
|
233
272
|
// uncached on every first run (Growth funnel F8). Left to the keeper,
|
|
@@ -236,7 +275,7 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
236
275
|
// the keeper's tsbuildinfo calls built, missing or not, so its save
|
|
237
276
|
// could be a partial dist.
|
|
238
277
|
const sharedFiles = c.files.filter((gb) =>
|
|
239
|
-
kept.some((k) => k.files.some((ga) =>
|
|
278
|
+
kept.some((k) => k.files.some((ga) => hits(k, ga, gb))),
|
|
240
279
|
)
|
|
241
280
|
if (
|
|
242
281
|
sharedFiles.length === c.files.length &&
|
|
@@ -249,7 +288,7 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
|
|
|
249
288
|
continue
|
|
250
289
|
}
|
|
251
290
|
dropped.add(c.index)
|
|
252
|
-
const shared = c.files.find((gb) => clash.files.some((ga) =>
|
|
291
|
+
const shared = c.files.find((gb) => clash.files.some((ga) => hits(clash, ga, gb)))!
|
|
253
292
|
delete t.task!['cache']
|
|
254
293
|
t.todos.push(
|
|
255
294
|
`declares the output ${JSON.stringify(shared)} that ${JSON.stringify(clash.name)} also ` +
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -385,6 +385,12 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
385
385
|
* the reads off too, and every task after the first upload missed.
|
|
386
386
|
*/
|
|
387
387
|
private writesRefused = false
|
|
388
|
+
/**
|
|
389
|
+
* Keys this process saw the server lack (a 404, a batch answer): `put`
|
|
390
|
+
* asks no HEAD for them. Turbo's PUT replaces a stored artifact and an
|
|
391
|
+
* entry is immutable, so any other key is asked first.
|
|
392
|
+
*/
|
|
393
|
+
private readonly absent = new Set<string>()
|
|
388
394
|
private readonly key: Uint8Array | undefined
|
|
389
395
|
readonly endpoint: string
|
|
390
396
|
/** What `TURBO_REMOTE_CACHE_READ_ONLY` and friends leave this run (`turboRemoteAccess`). */
|
|
@@ -517,7 +523,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
517
523
|
const res = await this.request('HEAD', `/${hash}`)
|
|
518
524
|
if (res === undefined) return false
|
|
519
525
|
if (res.status === 200) return true
|
|
520
|
-
if (res.status === 404)
|
|
526
|
+
if (res.status === 404) {
|
|
527
|
+
this.absent.add(hash)
|
|
528
|
+
return false
|
|
529
|
+
}
|
|
521
530
|
throw new Error(`HTTP ${res.status}`)
|
|
522
531
|
}
|
|
523
532
|
|
|
@@ -538,12 +547,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
538
547
|
// Each hash answers ArtifactInfo, null, or `{ error: { message } }`; an
|
|
539
548
|
// error entry is not a stored artifact, and it was counted as one (a GET
|
|
540
549
|
// spent on a 404, item 929).
|
|
541
|
-
|
|
550
|
+
const held = new Set(
|
|
542
551
|
hashes.filter((h) => {
|
|
543
552
|
const v = info[h]
|
|
544
553
|
return typeof v === 'object' && v !== null && !('error' in v)
|
|
545
554
|
}),
|
|
546
555
|
)
|
|
556
|
+
for (const h of hashes) if (!held.has(h)) this.absent.add(h)
|
|
557
|
+
return held
|
|
547
558
|
}
|
|
548
559
|
|
|
549
560
|
async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
|
|
@@ -556,7 +567,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
556
567
|
if (res === undefined) return null
|
|
557
568
|
if (res.status !== 200) {
|
|
558
569
|
await res.body?.cancel()
|
|
559
|
-
if (res.status === 404)
|
|
570
|
+
if (res.status === 404) {
|
|
571
|
+
this.absent.add(hash)
|
|
572
|
+
return null
|
|
573
|
+
}
|
|
560
574
|
throw new Error(`HTTP ${res.status}`)
|
|
561
575
|
}
|
|
562
576
|
const duration = Number(res.headers.get('x-artifact-duration'))
|
|
@@ -608,6 +622,16 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
608
622
|
|
|
609
623
|
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
610
624
|
if (this.disabled || this.writesRefused) return
|
|
625
|
+
if (!this.absent.has(hash)) {
|
|
626
|
+
// Part of the upload, so under its deadline. Any answer but 200 or 404
|
|
627
|
+
// uploads: a lost store costs more. A server the HEAD cannot reach
|
|
628
|
+
// fails the put, as the PUT would.
|
|
629
|
+
const head = await this.request('HEAD', `/${hash}`, {
|
|
630
|
+
timeoutMs: this.config.uploadTimeoutMs,
|
|
631
|
+
})
|
|
632
|
+
if (head === undefined || head.status === 200) return
|
|
633
|
+
if (head.status === 404) this.absent.add(hash)
|
|
634
|
+
}
|
|
611
635
|
const headers: Record<string, string> = {
|
|
612
636
|
'Content-Type': 'application/octet-stream',
|
|
613
637
|
'Content-Length': String(body.size),
|
|
@@ -625,6 +649,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
625
649
|
// Turbo's client takes any 2xx (`error_for_status`); a server's 201 or
|
|
626
650
|
// 204 stored the artifact and was counted a failed upload.
|
|
627
651
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
652
|
+
this.absent.delete(hash)
|
|
628
653
|
}
|
|
629
654
|
}
|
|
630
655
|
|