@vzn/vx-migrate 0.0.637 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 every 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 per run 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.
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,7 +62,7 @@ Rules:
62
62
  - A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: a keyed group (no command, nothing spawns) keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` of their own). A dependency without the script whose own edges lack `^x` gets it too, so a `^x` stops there as Turbo's does (opencode's `build: { dependsOn: [] }`: core walked past to the builds below, which Turbo's `^build` never reaches).
63
63
  - `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
64
64
  - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx 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 each run'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.
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
 
@@ -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 once per run; 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.
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 once per run for all the tasks that carry each one. `bunx @vzn/vx-migrate --dry --from nx` lists the same set once.
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, in one line before `next:`. A refusal is printed 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`.
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 opens `no package.json scripts became tasks.` rather than
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:` in the report) rather than writing a second one
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.637",
3
+ "version": "0.0.638",
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.637"
43
+ "@vzn/vx": "^0.0.638"
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
- process.stdout.write(`vx-migrate: ${line}${why}\n`)
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(
@@ -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: { notes: mapping.gaps.notes, todos } }
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
@@ -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 = await unlistedProjects(root, metas, mapped.projects)
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
- 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
55
- 'are frozen as static config; `nx:run-commands` targets are their shell lines, and ' +
56
- 'every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate ' +
57
- 'installed until those targets are rewritten as commands); targets with `.env` files ' +
58
- 'run through `nx-env`',
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
- throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
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
- process.stdout.write(
218
- `vx is already set up here (${existing} and the projects' vx.config files); --force regenerates them from ${runnerFile}.\n\nnext: ${adoptionNext(root, runner, runnerFile, ['@vzn/vx'])}\n`,
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 (!existsSync(path.join(root, 'node_modules', '.bin', 'nx'))) {
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 = writesWorkspace
298
- ? [
299
- ...kept,
300
- ...workspacePlugins(root, runner === 'vite-task' ? undefined : runner).filter(
301
- (p) => !kept.some((k) => k.factory === p.factory),
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 headerNotes = refused
344
- ? []
345
- : [
346
- ...(existingWorkspace !== undefined && workspaceText !== undefined
347
- ? await rewriteWorkspace(root, existingWorkspace, workspaceText, adopted!, parsed.dry)
348
- : []),
349
- ...(await prepareRepo(
350
- root,
351
- [
352
- ...new Set([
353
- '@vzn/vx',
354
- ...(needsSelf ? ['@vzn/vx-migrate'] : []),
355
- ...plugins.map((p) => p.pkg),
356
- ]),
357
- ],
358
- parsed,
359
- needsSelf ? [] : ['@vzn/vx-migrate'],
360
- )),
361
- ...(cacheNote === undefined ? [] : [cacheNote]),
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
- notes: headerNotes,
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
- return await Bun.spawn([process.execPath, '--no-install', coreBin(root), 'lock'], {
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', 'inherit', 'inherit'],
387
- }).exited
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 line; writes nothing under `--dry`.
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<string[]> {
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} (dry run)`]
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 lines; changes nothing under `--dry`.
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<string[]> {
489
+ ): Promise<Step[]> {
458
490
  if (args.noInstall === true) return []
459
- const notes: string[] = []
491
+ const steps: Step[] = []
460
492
  const version = ownVersion()
461
493
  const missing = missingPackages(root, wanted, version)
462
494
  if (missing.length > 0) {
463
- if (args.dry) notes.push(`would install ${missing.join(' ')} (dry run)`)
464
- else notes.push(`installed ${missing.join(' ')} (${await install(root, missing, version)})`)
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
- if (args.dry) notes.push(`would remove ${listed.join(' ')} (dry run)`)
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 notes
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
- for (const note of await prepareRepo(
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
- ;(args.json ? process.stderr : process.stdout).write(`vx-migrate: ${note}\n`)
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
- ? args.dry
530
- ? `would write ${name} (dry run, nothing written).`
531
- : `wrote ${name}.`
561
+ ? ['files', [name, ...ignore].join('\n')]
532
562
  : text === before
533
- ? `${existing} already declares ${runner}().`
534
- : `${args.dry ? 'would declare' : 'declared'} ${added.join(', ')} in ${existing}.`
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 locks = !args.dry && !next.includes(' lock && ')
567
- const code = locks ? await lockWorkspace(root) : 0
568
- process.stdout.write(`${locks ? '\n' : ''}next: ${next}\n`)
569
- return code
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
- ...noMembers,
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
@@ -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 linked = path.join(root, 'node_modules', '.bin', 'nx')
15
- const bin = (await Bun.file(linked).exists()) ? linked : lernasNx(root)
14
+ const bin = nxBin(root)
16
15
  if (bin === undefined) {
17
- return `no ${path.relative(root, linked)} — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
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 = [...loadNotes]
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
- notes.push(
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
- notes.push(
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
  }
@@ -1,5 +1,6 @@
1
- // What a mapping could not say, reported once per run by the plugin that
2
- // runs it live. One line per DISTINCT gap, not one per task: n8n has a
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(projects: readonly MappedProject[], notes: readonly string[]): Gaps {
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
- export function warnGaps(warn: (line: string) => void, pluginName: string, gaps: Gaps): void {
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) {