An Nx repo under vx with nothing written (2026-09-22)
Status: IMPLEMENTED (item 590, 2026-09-22) — nx-exec
(packages/vx-migrate/src/nx-exec.cjs), the nx() plugin
(src/nx/index.ts) and the migrator emitting nx-exec where it wrote a
placeholder, in one PR. Owner’s ask, 2026-09-22: “support nx more, like
their executors; people could start using vx with current nx configs, no
changes; of course this cannot be in core.” Then: “ideally we would have
a cli like nx-exec [executor] [options] and we would just translate to
that command.”
The shape
Section titled “The shape”turbo() already runs a Turbo repo unchanged: a project-stage plugin
maps turbo.json to tasks at config time. Nx has the same seam
available and one extra problem: most of its targets are not shell
commands but executors — a package’s JavaScript function that
receives an options object and an ExecutorContext. nx:run-commands
and nx:run-script are commands; @nx/js:tsc, @nx/vite:build,
@nx/jest:jest and every custom executor are not. Before this the
migrator mapped eight of them to a bare CLI under a todo and wrote a
placeholder for the rest.
Two pieces close the gap, both in @vzn/vx-migrate, nothing in core:
nx-exec, a bin that runs one executor as one process. The command line carries the executor and its options; Nx’s ownrunExecutordoes the option merging, schema defaults, validation and the call.nx(), aproject-stage plugin over the resolved Nx project graph.run-commandsandrun-scripttargets become their raw command (the migrator’s mapping, shared); every other executor becomes annx-execline.
Why runExecutor, and why not in vx’s process
Section titled “Why runExecutor, and why not in vx’s process”Nx’s devkit exposes runExecutor(target, overrides, context) publicly,
and nx run is a thin CLI over it. Calling it directly skips yargs,
plugin loading, the task orchestrator, the Nx cache and the daemon
handshake, and keeps executor resolution from the workspace’s own
node_modules, option merging with configurations, schema defaults,
{workspaceRoot} / {projectRoot} interpolation and validation.
It cannot run inside vx itself:
- vx is Bun; Nx and its executors are Node programs (the
@nx/nx-linux-x64-gnunative binding, jest workers, esbuild’s child). - A plugin changes WHERE a command runs, never what it is. A function call has no command: no sandbox, no env isolation, no kill grace, no peak RSS, no per-task stdout. Concurrent executors would interleave on one stream.
- Nx does not do it either: its task runner forks
run-executor.jsper task, or runs it under a pseudo-terminal, for the same reasons.
So nx-exec is one Node process per executed task, spawned by vx like
any command. It runs under the workspace’s Node, not Bun, because the
executor decides the runtime.
The command carries the options (owner’s shape, 2026-09-22)
Section titled “The command carries the options (owner’s shape, 2026-09-22)”The first sketch was nx-exec <project>:<target>, the host reading the
target from the cached graph. The owner asked for the explicit form:
nx-exec <executor> --project <name> --target <name> [--configuration <name>] --options '<json>'It is better in vx’s own terms:
- The key sees the options. Resolved-config hashing holds because
the options are in the command string; the
project:targetform would need a side channel. - No ambient state decides what runs. A stale graph cache would be
a hidden input in the
project:targetform, and a stale hit under a green run is the worst failure class. Here the graph only shapes the executor’s context; the plugin refreshes it once per run. vx showprints the truth, and the line pastes into a shell.- The migrator stops writing placeholders. Every executor migrates mechanically, and the written config keeps working as a team drifts off Nx one target at a time.
The trick that keeps it on public API: the host reads the cached
project graph for the ExecutorContext executors read (project root,
dependencies for buildable libraries), REPLACES that project’s target
in the in-memory graph with { executor, options } from the command
line, and calls runExecutor({ project, target, configuration }). Nx’s
merge, defaults and validation run unchanged; what runs is what the
command says, never what project.json says today. Proven on the
bench: a @nx/js:tsc build with an outputPath unlike project.json’s
wrote to the command’s path.
Options travel as one JSON argument, not Nx-style flags: arrays of
objects (assets) and nested run-commands lists do not round-trip
through dotted flags. --project and --target stay because executors
read them from the context (tsc’s buildable-dependency lookup finds the
same target on each dependency).
Nx resolves {projectRoot}, {workspaceRoot} and {projectName} when
it BUILDS the graph, not when it runs: the cached graph holds
cwd: "packages/p3" for a project.json that says {projectRoot}.
Proven 2026-09-22. So the plugin and the migrator read the resolved
graph and the command is literal. Configurations are the one thing the
graph keeps unflattened; the plugin flattens the chosen one at config
time.
context.taskGraph stays undefined on purpose. @nx/js:tsc and
@nx/webpack read it only under NX_BUILDABLE_LIBRARIES_TASK_GRAPH,
and a synthesised one-task graph would answer “no buildable
dependencies” where the project-graph path walks the real edges. An
absent task graph sends them down the project-graph path.
Measured (2026-09-22, Node 22.22.2, Nx 22.7.12, synthetic workspace)
Section titled “Measured (2026-09-22, Node 22.22.2, Nx 22.7.12, synthetic workspace)”Per executed task, one spawned process each, interleaved arms, medians
in ms. Scratchpad nxbench/, nxbench-results.md.
nx:run-commands running true, 200 projects, N=10:
| arm | median |
|---|---|
sh -c true | 2 |
node -e 0 | 27 |
host, nx/src imports | 245 |
host + NODE_COMPILE_CACHE | 214 |
host, @nx/devkit barrel | 292 |
nx run … --skip-nx-cache --exclude-task-dependencies, daemon off | 656 |
nx run, daemon warm | 346 |
Same at 1,000 projects, N=6: host 272, nx run daemon off 1,104,
daemon warm 387. @nx/js:tsc build, 200 projects, dist removed
before each: host 1,495, daemon off 2,249, daemon warm 1,907.
Item 597 (2026-09-22): the bin enables Node’s compile cache for its own
process; the fair A/B (the same bin, cache disabled through
NODE_DISABLE_COMPILE_CACHE=1 against enabled, min-of-15) read 243 →
214 ms min and 272 → 233 median on the noop arm.
What it says: the host beats the CLI in every arm and the gap grows
with the workspace, because the CLI rebuilds the project graph per
invocation (with the daemon off, the only mode a sandbox allows) and
still hashes the task, checks outputs and writes run history with the
cache skipped. The host’s own floor is ~220 ms, nearly all of it Nx’s
module graph (require('nx/src/project-graph/project-graph') alone is
154 ms; the 531 KB graph parses in 4). That floor is paid only on a
miss; a warm vx run pays nothing. nx/src deep imports over the
devkit barrel: 50 ms and less variance, and @nx/devkit need not be
installed (a workspace with only nx and custom executors).
The plugin
Section titled “The plugin”- Source of truth: the resolved graph. Nx keeps one at
.nx/workspace-data/project-graph.jsonin its own shape, written by every daemon-less Nx command;nx graph --file=<path>writes{ graph: { nodes, dependencies } }. The mapper accepts both. - Freshness: once per run, the plugin stats
nx.json(and every file itsextendschain names, item 1050), every discovered project’sproject.jsonandpackage.json, and the snapshot; when the snapshot is older than any of them, or missing, it runsnode_modules/.bin/nx graph --file=<snapshot>and reads that. A plugin-inferred target from a file the rule does not stat (avite.config.tsedit that changes an inferred target) is refreshed by the next Nx command ornx graph --file; the README says so. The cost on a fresh snapshot is the stats, a few ms at 1,000 projects. - The mapping is
migrate-nx.ts’s, shared: what runs live is what the migrator would have written, minus the file, the same ruleturbo()keeps. A package’s ownvx.configwins; the plugin fills, never overwrites. - Executors:
nx:run-commands, a plaincommandandnx:run-scriptmap as before (shell,cdto where Nx ran it);nx:noopis a group; everything else isnx-exec <executor> --project … --target … --options '<json>'with the default configuration flattened in. Each other configuration is its own task,<target>:<configuration>, same inputs, outputs and edges,--configurationpassed so executors readingcontext.configurationNamesee it. - Lifetime: a target is persistent when it says
continuous: true, never when it sayscontinuous: false, and, in a graph from an Nx older than that field, when its executor is a known server (@nx/vite:dev-server, …). Never by its name: a cachednx:run-commandstarget nameddevran uncached on every run while the same target namedgencached (nx#32610). - What
nx-execneeds at run time:nxresolvable from the project dir (the workspace’snode_modules), Node on PATH, and the cached graph. It never dials the daemon.
run-commands as one shell line (2026-09-24)
Section titled “run-commands as one shell line (2026-09-24)”nx:run-commands stays a shell line, not an nx-exec one: the host’s
~220 ms floor per executed task is the price of running an executor, and
a shell command has none. So the line carries Nx’s option handling
itself — normalizeOptions in run-commands.impl.ts and the runners in
running-tasks.ts, identical in Nx 22.7 and 23.2 — and
tests/nx-exec-live.test.ts holds each shape against Nx’s own executor
run through nx-exec on the same options and arguments.
- Each command is a subshell, as Nx gives each a shell of its own: one
command’s
exit,cdorsetstays in it. commandsrun in parallel unlessparallel: false(the schema’s default). The line starts each as a background job whose failure sendsUSR1to the line’s shell; its trap TERMs the process group, waits for every command to exit (Nx settles them all before it fails the task, so a TERMed server’s cleanup trap runs inside the task) and exits 1, Nx’s code for a failed parallel run. The group is the task’s own: vx spawns every taskdetached, and a line pasted into a script should run undersetsid sh -cfor the same reason. Not under vx’s Linux sandbox: the runtime’s wrapper shell shares the group there, the TERM ends it, and the task fails 143 with the namespace killing the commands mid-trap (anykill 0does that to a sandboxed task, a core defect this line only inherits). Joined with&&, a failing check waited for a server that never exits (nx#28477).- Arguments: Nx appends every option it does not consume (
--k=v, quoted as it quotes), theargsoption and the command line’s arguments to each command, as TEXT. vx appendsvx run … -- <args>to the end of the line, so a line of more than one command is a function, and a helper appends"$@"to each command only when there are arguments: an unconditional"$@"afterdoneis a syntax error with none (nx#12165).{args.name}is filled from the options at map time; one given after--does not reach it (a todo). nx-execpasses what it does not know to Nx’s owncreateOverrides, and hands the result torunExecutor.runExecutorderives the unparsed list from the parsed overrides and puts positional words first, wherenx runkeeps the typed order; the parity rows type positionals first for that reason.envisexec.env.define(the key sees it),colorsetsFORCE_COLOR,readyWhenmakes the task persistent with the escaped string as its pattern. Nx waits for EVERYreadyWhenstring; vx takes one pattern, so several are an alternation and a todo. Nx also fails a run whosereadyWhenmatched on stderr; vx does not.commands: []istrue: Nx completes it at once (nx#31345).- Reported and not reproduced: per-command
prefix/color,streamOutput: false,__unparsed__in a graph.usePty,ttyandverboseare display-only here: vx runs no task under a pty.
.env files at run time (2026-09-24)
Section titled “.env files at run time (2026-09-24)”Nx gives every task the .env files getEnvPathsForTask names (the
project’s, then the root’s, most specific first; the first definition
wins, the environment over all), and run-commands loads envFile after
them. Two ways to reproduce that were open:
- Read them at map time into
exec.env.define. The key would see the values. But the values would be in the config: a.env.localsecret invx show, invx-lock.json, and in everyvx.config.tsthe migrator writes — and the migrator could not write them, so the plugin and the migrator would stop producing the same task. - Load them when the task runs (chosen). The mapper lists which exist
(one
readdirper project dir per run, ~4 ms at 1,000 projects, measured with 1,000 dirs of five files each) and the command names them:nx-env --dotenv <f>… [--envFile <f>] -- '<line>'for a shell line,--dotenv <f>on annx-execline. Both bins load through Nx’s ownloadAndExpandDotEnvFile, so parsing,${VAR}expansion and precedence are the workspace’s Nx version’s. A cached task keys on the files’ bytes with acache.inputs.runtimeprobe that prints each name and its bytes: a glob cannot see a gitignored.env.local, and the name keeps a line moved between files (a different precedence) a different key. A file added or removed changes the command.
Nx unloads the root files it loaded into its own process at start-up
before loading a task’s; nothing loaded them under vx, so the bins do not
unload, and the target’s env (exec.env.define, present before the
files load) stays on top, as in Nx. NX_LOAD_DOT_ENV_FILES=false in vx’s
environment at map time drops the files and envFile, as Nx does.
nx-env takes --envFile, not --env-file: Node 22 reads --env-file
from anywhere on its command line and exits 9 when the file is missing.
^ inputs over the project graph (2026-09-26, item 910)
Section titled “^ inputs over the project graph (2026-09-26, item 910)”Nx hashes ^production as each dependency’s production input,
transitively over the PROJECT graph, whether or not the task has a ^
edge (hash_planner.rs, gather_dependency_inputs). The mapper dropped
it as “folded through dependsOn”, which holds only along a task edge, so
the stock test: { inputs: ['default', '^production'] } hit after a
dependency’s source changed. It also read namedInputs from nx.json
alone; Nx merges an implicit default ({projectRoot}/**/*), nx.json’s
and the project’s own, in that order.
Listing the closure’s globs on every task is correct and does not scale:
2.5 million workspace globs and 1.5 s of mapping at 1,000 projects in a
deep graph. So each project gets an nx-input:<name> twin — true,
cached, keyed on its own name input, with an edge to each Nx
dependency’s twin — and a task reading ^name depends on its direct
dependencies’ twins. vx folds upstream keys along edges, so the closure
reaches the key with each project hashed once. The edges are explicit
pkg#nx-input:<name> on the Nx graph’s edges (vx’s ^ follows
package.json, and an Nx edge from a tsconfig path has no manifest
entry). A graph node with no vx project is walked through, its files
joining as workspace globs; inside a project cycle, which vx’s task
graph refuses, a twin carries its peers’ files and edges only out of the
cycle.
Measured (Linux box, interleaved, min of 15): mapping at 1,000 projects
91 → 144 ms; a warm run of build test lint at 300 projects in a deep
graph 159 → 256 ms, the 598 twins at about 0.16 ms each. The “before”
arm is the stale-hit mapping. A key-only node in core (no spawn, no
history row) would take most of it back; STATUS Next.
What it does not do
Section titled “What it does not do”- Nx’s configuration propagation (
^buildunder--configuration productionbuilds dependencies withproductionwhere they have it) is a todo on the<target>:<configuration>task; its edges are the base target’s. - Batch executors (
NX_BATCH_MODE) run one task per process here. - Task-graph-aware executors under
NX_BUILDABLE_LIBRARIES_TASK_GRAPHsee no task graph and take the project-graph path. - Nothing in core. Core keeps
vx init; the Nx reader, the bin and the plugin are@vzn/vx-migrate’s.