@vzn/vx 0.0.632 → 0.0.634

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.
Files changed (48) hide show
  1. package/README.md +14 -10
  2. package/docs/caching.md +2250 -0
  3. package/docs/cli.md +3283 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +174 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2151 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +12 -5
  10. package/schemas/docs.json +28 -0
  11. package/schemas/error.json +5 -1
  12. package/schemas/init.json +57 -0
  13. package/schemas/lock.json +21 -0
  14. package/schemas/plan.json +4 -0
  15. package/skills/vx/SKILL.md +13 -3
  16. package/src/cache/cache.ts +556 -122
  17. package/src/cache/chained-cache.ts +9 -0
  18. package/src/cache/inputs.ts +71 -30
  19. package/src/cache/layer.ts +8 -4
  20. package/src/cache/layered-cache.ts +4 -0
  21. package/src/cache/schema.ts +42 -16
  22. package/src/cli/docs-corpus.ts +21 -0
  23. package/src/cli/docs-text.d.ts +5 -0
  24. package/src/cli/docs.ts +173 -0
  25. package/src/cli/help.ts +14 -3
  26. package/src/cli/index.ts +2 -0
  27. package/src/cli/init.ts +44 -8
  28. package/src/cli/lock.ts +43 -15
  29. package/src/cli/plan-format.ts +3 -1
  30. package/src/cli/run.ts +8 -2
  31. package/src/cli/upgrade.ts +18 -6
  32. package/src/config.ts +9 -0
  33. package/src/graph/priorities.ts +72 -6
  34. package/src/graph/scheduler.ts +17 -4
  35. package/src/graph/task-graph.ts +56 -18
  36. package/src/orchestrator/metrics.ts +11 -4
  37. package/src/orchestrator/miss-reason.ts +3 -5
  38. package/src/orchestrator/miss-save.ts +6 -5
  39. package/src/orchestrator/projects.ts +2 -2
  40. package/src/orchestrator/remote-prefetch.ts +8 -2
  41. package/src/orchestrator/run.ts +13 -1
  42. package/src/orchestrator/task-hash.ts +4 -4
  43. package/src/util/errors.ts +9 -2
  44. package/src/util/paths.ts +32 -0
  45. package/src/util/verbs.ts +1 -0
  46. package/src/workspace/config-schema.ts +26 -8
  47. package/src/workspace/lockfile.ts +29 -2
  48. package/src/workspace/migration.ts +30 -3
package/docs/schema.md ADDED
@@ -0,0 +1,2151 @@
1
+ # Config schema
2
+
3
+ Complete reference for every field accepted by `vx.config.{ts,mts,js,mjs,cts,cjs}`
4
+ and the optional workspace-level `vx.workspace.{ts,mts,js,mjs,cts,cjs}`. The
5
+ authoritative TypeScript definitions live in `src/config.ts` and are
6
+ re-exported from `@vzn/vx`.
7
+
8
+ ## File layout
9
+
10
+ ```
11
+ <workspaceRoot>/
12
+ ├── pnpm-workspace.yaml (or: package.json with "workspaces", or bare package.json)
13
+ ├── vx.workspace.ts (optional, workspace-wide settings)
14
+ └── packages/
15
+ └── <pkg>/
16
+ ├── package.json
17
+ └── vx.config.ts (per-package task definitions)
18
+ ```
19
+
20
+ A project is discovered as a vx project by the **presence of a
21
+ `vx.config.*` file alongside the project's `package.json`.** Projects
22
+ without a config are visible in the workspace graph but contribute no
23
+ tasks.
24
+
25
+ ## Project config
26
+
27
+ ```ts
28
+ import { defineProject } from '@vzn/vx/config'
29
+
30
+ export default defineProject({
31
+ tags: ['scope:web', 'type:app'],
32
+ tasks: {
33
+ <taskName>: TaskConfig,
34
+ ...
35
+ },
36
+ })
37
+ ```
38
+
39
+ ```ts
40
+ interface ProjectConfig {
41
+ tags?: readonly string[] // labels for `--filter tag:<name>`
42
+ tasks?: Record<string, TaskConfig> // keyed by task name
43
+ }
44
+ ```
45
+
46
+ `defineProject` is an identity function — it exists purely so
47
+ TypeScript narrows literal types in your config (clean autocomplete,
48
+ strict validation against the schema). It has zero runtime effect.
49
+ The same typing without a runtime import is
50
+ `import type { ProjectConfig } from '@vzn/vx/config'` plus
51
+ `export default { … } satisfies ProjectConfig` — the form `vx init`
52
+ and `@vzn/vx-migrate` generate, because Bun erases the type import and the
53
+ file then loads in a workspace that runs the `vx` binary without the
54
+ package installed. Either form is fine; the object is what vx reads.
55
+ `@vzn/vx/config` is the schema alone (`src/config.ts`, which imports
56
+ nothing): your own `tsc` checks that one file, where `@vzn/vx` would
57
+ walk core's Bun-only sources and fail without `@types/bun`.
58
+
59
+ The object is JSON data. The cache key folds `JSON.stringify` of each
60
+ task's config, `vx lock` stores the same JSON, and `vx watch` re-reads a
61
+ config through a worker that hands it back as JSON, so a value JSON
62
+ cannot carry would be dropped, rewritten or refused depending on the
63
+ path. vx refuses it on every path, before the schema: a function, a
64
+ symbol, a bigint, `NaN` or `±Infinity`, `undefined` inside an array
65
+ (JSON writes `null`), a cycle, a getter or setter (code that each read
66
+ may answer differently; vx never calls it), and any object that is not a
67
+ plain object or an array (a `Date`, `Map`, `Set`, `RegExp` or class
68
+ instance). An
69
+ `undefined` property is fine: JSON drops it and the schema reads it as
70
+ absent, which is what a conditional spread
71
+ (`...(ci ? { retries: 2 } : {})`) relies on. The workspace config is not
72
+ held to this: its plugins are objects of functions.
73
+
74
+ `tasks` is a `Record<string, TaskConfig>`. Task names are strings
75
+ referenced by `dependsOn`, by `cache.inputs.tasks`, and by the CLI
76
+ (`vx run <taskName>` or `vx run <pkg>#<taskName>`), so a name those
77
+ could not spell is refused at load: an empty one, one with surrounding
78
+ whitespace, one holding `#` (the project separator) or `*` (a pattern),
79
+ and one starting with `^` (dependencies' tasks) or `!` (a negation).
80
+
81
+ `tags` label the project for selection: `--filter tag:<name>` selects
82
+ the projects carrying one (`cli.md` § Filter DSL), as Nx's `tag:` does.
83
+ Each is a non-empty string a tag filter can name, or it is refused at
84
+ load: no surrounding whitespace, no `*` (a pattern), and no ending in
85
+ `...` (a dependency walk) or `[<ref>]` (a git range). A
86
+ `project` plugin may set or edit them (`nx()` gives each project its Nx
87
+ `tags` unless the vx.config has its own). A tag is in no cache key: it
88
+ changes no task's behaviour, so editing one re-runs nothing.
89
+
90
+ ## `TaskConfig`
91
+
92
+ ```ts
93
+ interface TaskConfig {
94
+ description?: string // one-line blurb for the picker / --dry view
95
+ exec?: ExecConfig // omit to declare a group task
96
+ dependsOn?: readonly string[] // Turbo/Nx micro-syntax
97
+ cache?: CacheConfig // caching is opt-in; on a group, a key with no outputs
98
+ }
99
+ ```
100
+
101
+ A task either has an `exec` (it does work) or omits `exec` and declares
102
+ `dependsOn` or `cache` (it's a **group task**: an aggregator, or a key).
103
+ The loader rejects a task that has none of them — a no-op standalone
104
+ task is almost always a config mistake. `dependsOn: []` is the deliberate form: an
105
+ explicit empty group, for a package that wants a task by that name to
106
+ exist and do nothing.
107
+
108
+ A project whose config and plugins declare no `build` gets one (owner,
109
+ 2026-10-04): a group with `dependsOn: ['^build']` keyed on every file of
110
+ the project (`cache.inputs.files: ['**']`, no outputs). It runs nothing,
111
+ but a dependant behind `^build` folds its key, so a package consumed as
112
+ source moves its dependants' keys and reaches them under `--affected`.
113
+ A config can declare a keyed group too (Group tasks, below). A
114
+ bare `vx run build` does not select it: run where no selected project
115
+ declares `build`, it is refused as any undeclared name is, while
116
+ `lib#build` names it and a dependant's `^build` reaches it. `vx show`
117
+ and `vx info` count it; `vx show build` and the `vx build` hint, which
118
+ name declared tasks, do not.
119
+
120
+ ### `description` (optional)
121
+
122
+ ```ts
123
+ description?: string
124
+ ```
125
+
126
+ A short one-line blurb describing what the task does. No effect on
127
+ scheduling or execution, but it **does** participate in the cache key —
128
+ the key hashes the whole resolved task config (see `caching.md`, step 5),
129
+ and carving exceptions out of that object is what invites stale hits.
130
+ Editing a description therefore costs one re-run. Surfaced in three
131
+ places:
132
+
133
+ - The interactive task picker (`vx run` with no positional in a TTY) —
134
+ printed to the right of each `pkg#task` id.
135
+ - The `--dry` text preview — printed on a second indented line under
136
+ the cache-status row.
137
+ - The `--dry=json` output — `description` lives on each task entry.
138
+
139
+ ```ts
140
+ test: {
141
+ description: 'bun test against the tests/ tree',
142
+ exec: { command: 'bun test' },
143
+ cache: {
144
+ inputs: { files: ['src/**', 'tests/**'] },
145
+ outputs: { files: [] },
146
+ },
147
+ }
148
+ ```
149
+
150
+ ### `exec` (optional — required for non-group tasks)
151
+
152
+ ```ts
153
+ interface ExecConfig {
154
+ command: string // shell command, run from the project's dir
155
+ remote?: boolean | 'only' // placement: pin here, or remote-only (see below)
156
+ env?: ExecEnv // optional per-task env layering
157
+ timeout?: number // ms before vx SIGTERMs the child (see below)
158
+ retries?: number // max additional attempts after a failure (see below)
159
+ persistent?: PersistentConfig // long-running task (dev server, watcher)
160
+ interactive?: boolean // reads the terminal: a prompt, a REPL (see below)
161
+ sandbox?: SandboxConfig // opt-in OS sandbox for this command
162
+ }
163
+ ```
164
+
165
+ `command` is a string. Run via `sh -c` (`dash` on macOS, as Linux's
166
+ `sh` is), so POSIX
167
+ shell semantics work directly — pipes, redirects, `&&` chaining:
168
+
169
+ ```ts
170
+ exec: {
171
+ command: 'tsc -b'
172
+ }
173
+ exec: {
174
+ command: 'gen && tsc && cp -r assets dist/'
175
+ }
176
+ exec: {
177
+ command: 'find src -name "*.snap" | xargs rm -f'
178
+ }
179
+ ```
180
+
181
+ CLI args after `--` are appended to `command`, each POSIX single-quoted
182
+ unless it holds only safe characters:
183
+
184
+ ```sh
185
+ vx run test -- --bail 'a b'
186
+ # child sees: bun test --bail 'a b'
187
+ ```
188
+
189
+ Forwarded args are folded into the cache key — different args produce
190
+ distinct cache entries.
191
+
192
+ #### `timeout` (optional)
193
+
194
+ Maximum time in milliseconds before vx SIGTERMs the child. Omitted →
195
+ no limit.
196
+
197
+ ```ts
198
+ build: { exec: { command: 'tsc -b', timeout: 120_000 } }
199
+ ```
200
+
201
+ - For a **normal task**, `timeout` bounds the total run time, counted
202
+ from the spawn (a sandbox's setup, its wrap included, is vx's, not the
203
+ task's). A task
204
+ that overruns is killed — its whole process group, so what it forked
205
+ goes with it — and reported `failed` (timed out) — never cached. (A timeout SIGTERM is a real failure, distinct from a Ctrl-C
206
+ teardown, which is reported `aborted`.)
207
+ - For a **persistent task**, `timeout` bounds the **readiness wait**
208
+ instead: if `readyWhen` hasn't matched within the window the child is
209
+ SIGTERMed and the task fails — see `persistent` below. A persistent
210
+ task that's ready on spawn (no `readyWhen`) becomes ready before the
211
+ timer can fire, so the timeout is a no-op for it.
212
+ - On a **plugin executor**, the timeout aborts the request's `signal`
213
+ instead (core cannot kill a process the executor spawned); any exit
214
+ after it is reported timed out, a 0 from a child that trapped the stop
215
+ included (X-125), and an executor still running after the kill grace
216
+ is abandoned (H-12, H-14).
217
+
218
+ **Upper bound.** `timeout` must be at most **2147483647 ms (~24.8 days)**,
219
+ the largest delay a timer can hold. A larger value does _not_ mean "no
220
+ limit" — the platform silently reduces it to **1 ms**, so the task would
221
+ be killed the moment it spawns and reported `failed`. vx refuses it at
222
+ load rather than let that happen. **Omit `timeout` entirely for no
223
+ limit.** The same bound applies to `--timeout` and to the workspace-level
224
+ `timeout`; the `VX_TASK_TIMEOUT` env rung is clamped to it instead of
225
+ refused, since that rung never fails a run.
226
+
227
+ A task with **no** `exec.timeout` falls back to a run-level default, if
228
+ one is set. Precedence, highest first: **per-task `exec.timeout` →
229
+ `--timeout <ms>` / `RunOptions.timeout` → `VX_TASK_TIMEOUT` env →
230
+ workspace `timeout`** (see workspace config below). Per-task always
231
+ wins. The run-level defaults are threaded as run options, so — like
232
+ `--retry` — they never touch a cache key: a `--timeout` run cache-hits a
233
+ plain run's entry.
234
+
235
+ **Why no multi-step `commands: string[]`?** Per-task caching is the
236
+ right granularity for invalidation. If you'd benefit from caching each
237
+ step independently (e.g. `codegen` then `build`), split into two
238
+ tasks linked by `dependsOn`. If you don't need that, `&&` in shell is
239
+ the right tool.
240
+
241
+ #### `retries` (optional)
242
+
243
+ Maximum ADDITIONAL attempts after a failed attempt. `retries: 2` means
244
+ up to 3 executions total. `0` / omitted → no retries (fail on the first
245
+ non-zero exit).
246
+
247
+ ```ts
248
+ test: { exec: { command: 'bun test', retries: 1 } }
249
+ ```
250
+
251
+ - A retry fires after ANY failure, `timeout` kills included. A Ctrl-C
252
+ teardown (`aborted`) is never retried — the run is tearing down.
253
+ Nor is a task in flight when `--continue=never` stops the run: its
254
+ attempt finishes, and its failure is the last, even when its retry
255
+ was already announced and preparing.
256
+ - Declared outputs are re-cleaned before each retry, exactly like the
257
+ first attempt — a failed attempt's partial outputs can't leak into
258
+ the next.
259
+ - Every attempt streams its output live. Between attempts vx emits one
260
+ stderr line into the task's stream, ending in the reason:
261
+ `vx: retrying <id> (attempt <k>/<total>) after exit <code>`, or
262
+ `after a timeout` when the attempt was killed by `timeout`.
263
+ - The final outcome is the LAST attempt's: the first success wins (and
264
+ is what gets cached — its output only, not a concatenation of failed
265
+ attempts); if every attempt fails, the task is `failed` with the last
266
+ exit code and nothing is cached, as today.
267
+ - The task's reported duration sums every attempt; a saved entry keeps
268
+ the producing attempt's time.
269
+ - `retries` is part of the resolved config, so declaring it derives a
270
+ distinct cache key (like every config field); tasks without it keep
271
+ byte-identical keys.
272
+ - Not allowed with `persistent` — a persistent task has no exit to
273
+ retry (config error, like `cache` + `persistent`).
274
+
275
+ The run-level default is `vx run --retry <n>` — it applies to tasks
276
+ that don't declare their own `retries`, never to a persistent one (a
277
+ server that exits before it is ready fails at once); explicit config
278
+ always wins, including an explicit `retries: 0`. The CLI flag never
279
+ affects cache keys.
280
+
281
+ #### `remote` (optional)
282
+
283
+ ```ts
284
+ remote?: boolean | 'only' // default true
285
+ ```
286
+
287
+ **Placement.** `false` pins the task to the machine that started the run:
288
+ it is never offered to an executor that declared itself `remote`. Use it
289
+ for a task that talks to something only this machine has — a local
290
+ daemon, a Docker socket, a device, a VPN-only host.
291
+
292
+ ```ts
293
+ 'docker:build': {
294
+ exec: { command: 'docker build -t app .', remote: false },
295
+ }
296
+ ```
297
+
298
+ - **Only meaningful with a remote executor declared.** A workspace with
299
+ no executor plugin already runs everything here, so the field changes
300
+ nothing.
301
+ - **Pinned by inference too.** A `persistent` task, and anything that
302
+ depends on one (transitively), is pinned regardless of what it
303
+ declares — a worker cannot reach a port served on the submitter. So is
304
+ a task with a `sandbox` block, and its dependants: the sandbox is this
305
+ machine's machinery, and a boundary a worker does not enforce would
306
+ pass vacuously. So is a task whose key folds `cache.inputs.runtime` or
307
+ `workspaceRuntime`: the key holds this machine's answer, which a worker
308
+ cannot prove it shares. Its dependants are not pinned.
309
+ - **Placement is decided once per task**, before scheduling, so the
310
+ scheduler knows which pool a task will occupy (see
311
+ [execution.md](execution.md#executor-pools)).
312
+ - **`'only'` is the inverse pin — the install-as-action recipe.** The task
313
+ exists to produce a REMOTE input tree (canonically `pnpm install` feeding
314
+ remote workers) and is a NO-OP on this machine: never executed locally,
315
+ its declared outputs never cleaned or restored on this disk. With a remote
316
+ executor declared it runs on a worker, its outputs stay in the remote
317
+ store, and dependents' input trees reference them there — the bytes flow
318
+ worker→store→worker without ever transiting the submitter. With no remote
319
+ executor it succeeds without running and dependents use the machine's
320
+ ambient state (the dev's own `node_modules`), exactly as before the field
321
+ existed. An `'only'` task must declare `cache` — its inputs are what a
322
+ worker reproduces and its key is the address of its remote record. One
323
+ without is refused at load.
324
+
325
+ ```ts
326
+ install: {
327
+ exec: { command: 'pnpm install --frozen-lockfile', remote: 'only' },
328
+ cache: {
329
+ inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
330
+ outputs: { files: ['node_modules/**'] },
331
+ },
332
+ },
333
+ build: { dependsOn: ['install'], /* … */ },
334
+ ```
335
+
336
+ - **Never busts a cache:** `remote` is stripped from the cache key —
337
+ `'only'` included, and it is the only `exec` field that is. The contract of a remote executor
338
+ is that the same command over the same inputs produces the same outputs,
339
+ so a key that moved with placement would split a laptop from a worker pool
340
+ over nothing.
341
+
342
+ #### `persistent` (optional)
343
+
344
+ ```ts
345
+ interface PersistentConfig {
346
+ readyWhen?: string // regex (as string); first matching line marks ready
347
+ }
348
+ ```
349
+
350
+ Marks the task as a long-running process — a dev server, a file
351
+ watcher, a daemon. The runner spawns the command but does NOT wait
352
+ for it to exit. Instead it considers the task "ready":
353
+
354
+ - Immediately on successful spawn when no `readyWhen` is given.
355
+ - On the first stdout/stderr line that matches the `readyWhen`
356
+ regex string. Each line is tested on its own, without its line
357
+ break (`\n`, `\r\n` or a bare `\r`) and without terminal escapes
358
+ (colour, OSC titles), so `^` and `$` anchor to a line as you read
359
+ it and `Local:` matches Vite's bold `Local` under `FORCE_COLOR`.
360
+ The trailing partial line is tested too, so prompt-style banners
361
+ without a newline (`printf 'Listening on :3000'`) count, and so does
362
+ output read up to 250 ms after the shell exits. A server
363
+ not ready after 10 s (`VX_READY_NOTICE_MS`) is said once, naming
364
+ the pattern it waits for, and whether `exec.timeout` bounds the wait.
365
+
366
+ ```ts
367
+ dev: {
368
+ exec: {
369
+ command: 'vite',
370
+ timeout: 30_000, // bound the readiness wait
371
+ persistent: { readyWhen: 'Local:' },
372
+ },
373
+ }
374
+
375
+ watch: {
376
+ exec: {
377
+ command: 'tsc --watch --preserveWatchOutput',
378
+ persistent: { readyWhen: 'Watching for file changes' },
379
+ },
380
+ }
381
+
382
+ // No readyWhen — ready as soon as spawn succeeds. Useful for daemons
383
+ // that have no observable "ready" signal but downstream doesn't need
384
+ // to gate on one.
385
+ agent: {
386
+ exec: {
387
+ command: 'my-agent --daemon',
388
+ persistent: {},
389
+ },
390
+ }
391
+ ```
392
+
393
+ Semantics:
394
+
395
+ - **Downstream unblocks on ready, not on exit.** Useful for e2e tests
396
+ that need a dev server up first:
397
+ ```ts
398
+ 'dev:run': { exec: { command: 'vite', persistent: { readyWhen: 'Local:' } } },
399
+ 'e2e': { dependsOn: ['dev:run'], exec: { command: 'playwright test' } },
400
+ ```
401
+ - **Not started when nothing needs it.** A persistent task you did not
402
+ request, whose every dependant is a confirmed local cache hit, is
403
+ never spawned: a fully cached `vx run e2e` does not boot the server
404
+ `e2e` depends on, and the server is in no outcome or count. It starts
405
+ if one of those hits turns out to need running (its artifact went),
406
+ and that task runs once it is ready. A dependant whose key the
407
+ server's writes could change (one in its own project, unless a sandbox
408
+ bounds the server's writes) is not probed ahead of it, so there the
409
+ server still starts. `vx run --dry` says so: such a server is
410
+ `not started` (`not-started` in `--dry=json`), not `would exec`.
411
+ - **Exit before ready ⇒ failed.** If the persistent task crashes or
412
+ exits before `readyWhen` matches, the task is reported as `failed`.
413
+ - **Crash after ready ⇒ failed run.** A persistent task that exits
414
+ non-zero (or is killed) on its own after it became ready fails the
415
+ run, and vx names it: `vx: <id> exited with code <n>` (a signal death
416
+ as its `128 + n` code), at once while the graph still runs (`… while
417
+ the run went on`), so a dependant failing against it reads why. Its own outcome is `failed` with that exit
418
+ code, and the footer counts it so (item 1071). An exit 0 on its own is
419
+ fine (a daemon that forks and returns), and one kept only as a
420
+ dependency does not end the foreground hold (WD-3).
421
+ - **End-of-graph SIGTERM.** Once the rest of the graph finishes
422
+ (success OR failure of downstream), the orchestrator sends `SIGTERM`
423
+ to every persistent subprocess it does not keep, and waits for them to
424
+ exit (`SIGKILL` past the kill grace). In the foreground it KEEPS the
425
+ persistent tasks you requested, those a requested group stands for,
426
+ and the persistent tasks they depend on: vx stays up after the summary
427
+ until one of them exits or you press Ctrl-C, streaming what they write.
428
+ A run where anything else failed keeps none and exits 1, unless
429
+ `--continue=always` (`cli.md` § Output, "Pinned persistent tasks").
430
+ - **`cache` is rejected.** The config loader throws on
431
+ `cache + persistent` — persistent tasks don't terminate, so there's
432
+ no exit code to cache and no outputs to capture at a well-defined
433
+ moment.
434
+
435
+ #### `interactive` (optional)
436
+
437
+ `true` hands the task the terminal: a prompt (`drizzle-kit push`), a
438
+ REPL, a watch mode that reads keys. Turbo's `interactive`.
439
+
440
+ - **On a TTY** (vx's stdin is one) the task gets vx's own stdin,
441
+ stdout and stderr. Its output is not framed, masked, kept or
442
+ replayed: it goes to the terminal as the task writes it. A frame
443
+ line opens before it and closes after (`--output-logs full`, and
444
+ the default views), and the live status region goes for the rest
445
+ of the run, as it would repaint over a prompt.
446
+ - **Alone.** It starts once nothing else runs, and nothing starts
447
+ until it exits. A persistent one holds the terminal from its spawn
448
+ to the run's end, so other tasks may run once it is spawned (their
449
+ output goes to the same terminal), a run may hold one, and every
450
+ other interactive task must be its dependency. A run that breaks
451
+ either is refused before any task runs.
452
+ - **Off a TTY** (CI, a pipe) it runs as any task: stdin is empty, or
453
+ a pipe vx holds for a persistent one, and it runs beside others.
454
+ A prompt there reads end of input; it never hangs.
455
+ - **Here, never cached.** No executor plugin is offered it, and
456
+ `cache` is refused. `sandbox` is refused (a sandboxed task's
457
+ output passes through vx), and so is `persistent.readyWhen` (vx
458
+ reads none of its output; it is ready once spawned).
459
+ - It runs in its own session, as every task does, so a tool that
460
+ opens `/dev/tty` itself rather than its stdin still cannot, and
461
+ the terminal's resize signal does not reach it. Ctrl-C reaches vx,
462
+ which stops the run; a task that puts the terminal in raw mode
463
+ reads the key itself.
464
+ - **Its terminal modes end with it.** vx reads them (`stty -g`)
465
+ before the task starts and puts them back when it exits, so a task
466
+ killed in raw mode, or one that never undid its `stty raw -echo`,
467
+ leaves no terminal without echo or Ctrl-C behind it
468
+ (`tests/terminal.unsafe.test.ts` › "a task that leaves the terminal
469
+ raw"). Without `stty` on PATH the modes stay the task's.
470
+
471
+ ```ts
472
+ 'db:push': {
473
+ exec: { command: 'drizzle-kit push', interactive: true },
474
+ }
475
+ ```
476
+
477
+ #### `ExecEnv` (optional)
478
+
479
+ ```ts
480
+ interface ExecEnv {
481
+ passThrough?: readonly string[] // names taken from host process.env; exact names, a wildcard is refused
482
+ define?: Record<string, string> // explicit name=value pairs
483
+ secret?: readonly string[] // names whose values are masked (`***`) whatever the name; see Masking
484
+ }
485
+ ```
486
+
487
+ The child process sees a deliberately limited env. From lowest to
488
+ highest priority:
489
+
490
+ 1. **Essential allowlist** (hard-coded in `src/exec/env.ts`, and pinned
491
+ against this list by a test): `PATH`, `HOME`, `SHELL`, `USER`,
492
+ `LOGNAME`, `TMPDIR`, `TEMP`, `TMP`, `LANG`, `LC_ALL`, `LC_CTYPE`,
493
+ `TERM`, `COLORTERM`, `FORCE_COLOR`, `NO_COLOR`, `CI`, `NODE_OPTIONS`,
494
+ `COREPACK_HOME`, `PNPM_HOME`.
495
+ Nothing else from the parent environment reaches a task —
496
+ that is the whole list. When neither `FORCE_COLOR` nor a non-empty
497
+ `NO_COLOR` reaches the task by any layer, vx sets `FORCE_COLOR=1`
498
+ (its own output strips the colour where it prints plain; `docs/cli.md`
499
+ § Colors). Without these, typical CLI tools break. vx
500
+ adds two markers of its own on top, `VX_RUN_WORKSPACE` (the root of
501
+ the workspace running the task) and `VX_RUN_TASK` (`project#task`):
502
+ a task whose command shells out to `vx run` in that same workspace
503
+ is refused — a loop back to itself forks a run per run without
504
+ bound, and a nested run that terminates is still invisible to the
505
+ outer graph (its tasks escape the schedule, the concurrency budget
506
+ and the cache key); declare it with `dependsOn`. Driving a
507
+ _different_ workspace from a task (a fixture suite, a benchmark) is
508
+ fine. **_NOT_ folded into the cache key** — the
509
+ same rule `passThrough` states below, and for the same reason.
510
+
511
+ Read that carefully if your build's _output_ depends on one of
512
+ them, because three can change what a task produces:
513
+ **`NODE_OPTIONS`** (`--require` / `--import` / `--loader` /
514
+ `--conditions` inject code or switch package-export resolution),
515
+ **`LC_ALL` / `LANG`** (collation — anything shelling out to `sort`
516
+ orders differently), and **`CI` / `FORCE_COLOR` / `TERM`** (output
517
+ bytes, and vx caches and replays output).
518
+
519
+ They are not hashed because hashing them would mean a laptop and a
520
+ CI runner could **never share a remote cache entry** — `PATH`,
521
+ `HOME` and `TERM` differ on every machine, which is the whole point
522
+ of a remote cache. vx does not guess which ones matter to your
523
+ build; if one does, say so explicitly:
524
+
525
+ ```ts
526
+ cache: { inputs: { files: ['src/**'], env: ['NODE_OPTIONS'] }, outputs: { files: [] } }
527
+ ```
528
+
529
+ That folds its value into the key. The two axes are orthogonal on
530
+ purpose: `cache.inputs.env` decides what the key _sees_,
531
+ `exec.env.passThrough` decides what the child _gets_.
532
+
533
+ 2. **`passThrough`** names → value taken from host `process.env` at
534
+ spawn time. A project config cannot set one for it: a first load
535
+ that writes `process.env` is refused (D-76). _NOT_ folded into the cache key — for secrets,
536
+ regional values, CI flags that legitimately vary between machines.
537
+ 3. **`define`** → explicit literal values. _ARE_ folded into the
538
+ cache key via the task config hash (the values are in your config
539
+ file, so they participate naturally).
540
+
541
+ ```ts
542
+ exec: {
543
+ command: 'bun test',
544
+ env: {
545
+ passThrough: ['CI', 'GH_TOKEN'], // values forwarded; cache-invariant
546
+ define: { NODE_ENV: 'test' }, // literal; in the cache key
547
+ },
548
+ }
549
+ ```
550
+
551
+ Every name in these lists (and in `cache.inputs.env`) must be one an
552
+ environment can hold: non-empty, with no `=` and no NUL, and a `define`
553
+ value holds no NUL. Such a name is refused at load; it used to reach the
554
+ child split at its `=` or not at all. A `passThrough` or `define` name is
555
+ also a shell variable name, `[A-Za-z_][A-Za-z0-9_]*`: the task runs under
556
+ `sh -c`, and dash (Linux's `sh`) drops any other name from the environment
557
+ it hands the command, so `my.var` reached a task on macOS and never on
558
+ Linux. `cache.inputs.env` takes any such name: the key reads it from vx's
559
+ own environment.
560
+
561
+ vx also sets `npm_execpath` to the workspace's package manager (the root
562
+ `package.json`'s `packageManager`, else its lockfile, found on the root's
563
+ `node_modules/.bin` or `PATH`) unless a layer gives one, as `pnpm run`
564
+ does: npm-run-all calls that manager back and falls back to a global `npm`
565
+ without it. It names a binary on this machine, so it is not in the key.
566
+
567
+ Anything outside these three layers, the two variables vx sets for
568
+ the run (`VX_RUN_WORKSPACE`, `VX_RUN_TASK`) and `npm_execpath`, is invisible to the child:
569
+ a host credential (`SSH_AUTH_SOCK`, `GITHUB_TOKEN`) reaches a task only
570
+ when `passThrough` names it, held end to end by `env.test.ts` (a
571
+ sandboxed task with a restricted network also gets the sandbox's own
572
+ proxy, CA and temp-directory (`TMPDIR`, `TMP`, `TEMP`) values over these names:
573
+ `modules/sandbox-runtime.md` § The environment SRT sets). This
574
+ matches Turbo's `passThroughEnv` semantics and exists for two reasons:
575
+
576
+ - **Cache stability.** If every host env var entered the key, every
577
+ shell with a different `PS1` would cache-miss.
578
+ - **Determinism.** If host env leaked into the child, two machines
579
+ with different `FOO=bar` set would produce different outputs and
580
+ the user would never know why.
581
+
582
+ **Masking.** The value of a variable whose name holds `TOKEN`, `SECRET`,
583
+ `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL` (vx's own environment or a
584
+ task's `define`, six characters or more; not a name ending `_FILE`,
585
+ `_PATH` or `_DIR`, nor git's `GIT_CONFIG_KEY_<n>`) is printed as `***` wherever vx
586
+ shows it: the task's output and the line vx adds under a shell's 127 or
587
+ 126 (the command's first word), the stdout the cache keeps and a hit
588
+ replays, the command a cache entry stores (what `vx why` prints and a
589
+ remote cache receives), the `$ command` line, telemetry records,
590
+ `vx show`, the hashes `vx why` gives for such a variable in
591
+ `cache.inputs.env` (its value, unsalted: the row names it and its change), an executor's error, a plugin's warning or failure (a remote's reply; the failure masked in the error `run()` rejects with), any error `vx mcp` answers an agent with, a failed `cache.inputs` runtime probe's error (its command and output), and the run's own invocation line that `vx last` prints (a
592
+ secret passed after `--`) and its `--tag`s. So are every string of a
593
+ task `vx show` prints (a runtime probe, `readyWhen`, the description),
594
+ the description `--dry` and the task picker print, a runtime probe's
595
+ command as the run history stores it and `vx why` names its row (two
596
+ probes that mask alike are numbered, `echo *** (2)`), the readiness
597
+ notice's pattern, a sandbox violation line, `vx info`'s config errors,
598
+ the error a verb ends on (a plugin's failure, a config's own throw) and
599
+ `vx watch`'s `cycle failed` line. A multi-line value (a PEM
600
+ key) is also masked line by line, each line of six characters or more,
601
+ and a value holding a `'` also as a shell-quoted line spells it (`'\''`).
602
+ A value
603
+ split across two output chunks is still caught; the output holds back
604
+ that many characters until the next chunk. A plugin that reads a task's
605
+ config directly sees it as written. A secret whose name holds none of
606
+ those words (`GH_PAT`, `NPM_AUTH`) is listed in `exec.env.secret`, and is
607
+ then masked in the same places.
608
+
609
+ Two `node_modules/.bin` directories are prepended to `PATH` so
610
+ installed tools (`oxlint`, `vite`, etc.) work without `npx`: the
611
+ project's own, then the WORKSPACE ROOT's, where a monorepo's shared
612
+ tooling lives. Never a sibling project's, so sibling bins stay invisible
613
+ (project isolation). A directory whose path holds `:` (PATH's delimiter)
614
+ cannot be named in PATH and is left out; a task's 127 then says so.
615
+
616
+ ### `dependsOn` (optional)
617
+
618
+ Tasks that must complete successfully before this task runs.
619
+ Turbo/Nx-style micro-syntax — a flat array of strings:
620
+
621
+ ```ts
622
+ dependsOn?: readonly string[]
623
+ ```
624
+
625
+ | Form | Meaning |
626
+ | ------------ | ------------------------------------------------------------------ |
627
+ | `'name'` | Same-project task `name`. |
628
+ | `'^name'` | The `name` task in the nearest workspace deps that declare it. |
629
+ | `'pkg#name'` | The `name` task in a specific other package (cross-project edge). |
630
+ | `'name.*'` | Every OTHER same-project task matching the pattern (`*` = any). |
631
+ | `'^name.*'` | All matching tasks of the nearest deps that declare any (pattern). |
632
+
633
+ Examples:
634
+
635
+ ```ts
636
+ dependsOn: ['build'] // same-project build first
637
+ dependsOn: ['^build'] // build in every workspace dep first
638
+ dependsOn: ['codegen', '^build'] // both
639
+ dependsOn: ['lib#build', 'shared#test'] // cross-project edges
640
+ dependsOn: ['lint.*'] // every lint.<x> task in this project
641
+ dependsOn: ['^build.*'] // all build.<x> tasks of the nearest deps
642
+ ```
643
+
644
+ Semantics:
645
+
646
+ - **Same-project (`'name'`)** — name must exist in this project's
647
+ `tasks` map; missing is a hard error at graph-build time. When the
648
+ config is authored through `defineProject`, a bare entry is also
649
+ **type-checked against this config's task keys** — a typo is a
650
+ compile error (`'^name'` / `'pkg#name'` reference other projects
651
+ and stay free strings).
652
+ - **`'^name'`** — nearest-holder frontier. Walk the package dep graph
653
+ from this project's direct deps (`dependencies`, `devDependencies`,
654
+ `optionalDependencies`, and a `peerDependencies` entry on a
655
+ workspace sibling unless that edge would close a cycle — then the
656
+ consumer provides it and it orders nothing; an entry is a dep when
657
+ the package manager links it to the workspace package, so a range the
658
+ local version does not satisfy is a registry dependency, see
659
+ `modules/package-graph.md` § Which entries are edges); each path stops at the first dep
660
+ that declares the task and an edge is added to it (Turbo/Nx
661
+ direct-deps parity). The holder's own `dependsOn` is responsible
662
+ for anything deeper — chain `'^name'` in the holder to keep the
663
+ cascade going (the universal pattern). Deps that don't declare the
664
+ task are passed through, so a sparse dep doesn't break ordering to
665
+ deeper packages that do (sparse tasks across a workspace are normal
666
+ — not every package has a `lint`). Finding no holder at all is legal
667
+ too, as long as SOME project in the workspace declares `name`: a
668
+ preset spreads `'^build'` over packages whose deps have no `build`.
669
+ A `'^name'` that **no project in the whole workspace declares** is a
670
+ hard error at graph-build time, since it can only be a typo: "Task
671
+ app#test depends on ^biuld but no project in the workspace declares
672
+ biuld". Until 2026-09-24 it resolved to no edges and the run went
673
+ green (Nx#32779 is the same bug). A scoped run (an anchored
674
+ `app#test`, `--filter`, a project directory) loads only its projects
675
+ and their dependency closure; when a `'^name'` there finds no holder
676
+ and nothing loaded declares it, vx evaluates the remaining configs to
677
+ decide, and if one of them fails to load the name is let through
678
+ rather than failing the scoped run. A `'^name.*'` pattern matching
679
+ nothing anywhere stays legal, as `'name.*'` does.
680
+ - **`'pkg#name'`** — missing pkg or task is a hard error (you named
681
+ them explicitly). `'//#name'`, Turbo's spelling of the root package,
682
+ names the root project (D-39) here and in `cache.inputs.tasks`; with no
683
+ root project it is refused as such.
684
+ - **Patterns (`'name.*'` / `'^name.*'`)** — `*` matches any run of
685
+ characters in a task NAME; everything else is literal (the dotted
686
+ namespace convention needs no escaping). A same-project pattern
687
+ expands to every other matching task (the declaring task never
688
+ matches itself); **zero matches is legal** — a preset-spread pattern
689
+ needn't match in every project. A `'^pattern'` walks the same
690
+ nearest-holder frontier as `'^name'`, where a holder is a dep
691
+ declaring at least one matching task; the holder receives edges to
692
+ ALL its matches and stops the walk. Patterns are not supported in
693
+ the `'pkg#task'` form. (Nx 19.5 `build-*` parity.)
694
+ - **No bare wildcards or negation here.** `'*'` / `'^*'` / `'!form'`
695
+ belong in `cache.inputs.tasks` (filtering which upstream hashes
696
+ participate in this task's cache key), not in `dependsOn` (declaring
697
+ graph edges). The micro-syntax parser rejects them in this position.
698
+ - **Cycle detection** runs across the resolved graph at the end of
699
+ `buildTaskGraph`. Cycles throw with a path-formatted message.
700
+
701
+ ### `cache` (optional)
702
+
703
+ ```ts
704
+ interface CacheConfig {
705
+ inputs: CacheInputs // required when `cache` is provided
706
+ outputs: CacheOutputs // required when `cache` is provided
707
+ }
708
+ ```
709
+
710
+ **Caching is opt-in.** Omit `cache` and the task always runs (no read,
711
+ no write). Provide it and caching is active for that task. The
712
+ project loader requires _both_ `inputs` and `outputs` when `cache` is
713
+ set — declaring "what does this read?" and "what does it produce?" is
714
+ a deliberate, conscious choice.
715
+
716
+ **Why opt-in?** Defaulting caching to ON with implicit "all files / no
717
+ outputs" silently produces stale builds the moment a user forgets
718
+ to revisit the config. The cost of a single forgotten cache miss
719
+ (re-run a task) is much less than the cost of a stale cache hit
720
+ (ship broken artifacts).
721
+
722
+ #### `CacheInputs`
723
+
724
+ ```ts
725
+ interface CacheInputs {
726
+ files: readonly string[] // required
727
+ workspaceFiles?: readonly string[] // optional; workspace-root-relative
728
+ env?: readonly string[] // optional
729
+ runtime?: readonly string[] // optional; project-dir shell commands
730
+ workspaceRuntime?: readonly string[] // optional; workspace-root shell commands
731
+ tasks?: readonly string[] // optional; same micro-syntax as dependsOn
732
+ }
733
+ ```
734
+
735
+ ##### `inputs.files` (required)
736
+
737
+ Project-relative globs. `!`-prefix negates. A **literal** entry (no
738
+ glob character) names a file or a whole directory tree — `src` and
739
+ `src/` both mean everything under `src`, as in Turbo and `.gitignore`;
740
+ `!src` subtracts the tree. A `!` entry subtracts wherever it sits in the
741
+ list, so a literal file it covers (`['src/**', '!src/gen/**',
742
+ 'src/gen/keep.ts']`) would never be an input: that is refused at load.
743
+ Spellings a matcher would otherwise turn into
744
+ nothing are normalized: a leading `./` (`./src/**` is `src/**`, `!./gen`
745
+ is `!gen`), an inner `/./` segment, a doubled `//`, and a trailing `/` on
746
+ a pattern (`src/*/` is the trees under `src`, `src/*/**`). A bare `.` or
747
+ `./` names the project directory itself and is refused at load — write
748
+ `**`. Turbo's `$TURBO_DEFAULT$` and `$TURBO_ROOT$` and Nx's
749
+ `{projectRoot}`, `{workspaceRoot}`, `{projectName}` and `{options.…}`
750
+ (D-52), pasted from turbo.json or
751
+ project.json, are refused in every glob list with what to write instead
752
+ (D-50): vx expands none of them, so each matched no file and a task
753
+ keyed on `$TURBO_DEFAULT$` alone replayed a stale output. A glob that
754
+ starts with `^` (Nx's `^production`, the dependencies' named input) is
755
+ refused too (D-51): vx keys a task on its dependencies through
756
+ `dependsOn`. A bare named input (`default`) can be a directory, so it
757
+ is taken as one and only warns when it matches nothing.
758
+
759
+ The wildcards are `*`, `**`, `?` and a brace set `{a,b}`, which matches
760
+ what its alternatives match on their own: `{src/**,lib/**}` is `src/**`
761
+ plus `lib/**`. A bracket is a
762
+ **literal character**, not a character class: `app/[id]/**` is the route
763
+ directory `app/[id]` (Next.js, SvelteKit, Astro), never `app/i` or
764
+ `app/d`. The escaped spelling `app/\[id\]/**` (Turbo's) means the same
765
+ path. An input brace of one alternative (`{b}.ts`) is refused: it
766
+ would match `b.ts` and never a file named `{b}.ts`; write the one you
767
+ mean, `\{b\}.ts` for the braces. An extglob (`@(a|b)`, `!(x)`,
768
+ `+(…)`, `*(…)`, `?(…)`) is refused too: `Bun.Glob` reads it as literal
769
+ text or a plain wildcard, so the key would miss the files it names;
770
+ write `{a,b}`. A leading `!` is still the negation, so `!(group)/**`
771
+ takes a route group back. This holds for every task glob — `inputs.files`,
772
+ `inputs.workspaceFiles`, `outputs.files`, `outputs.workspaceFiles` — and
773
+ for everything read from them (`--affected`, `vx watch`, the
774
+ overlapping-output refusal). Package-manager member globs (`workspaces`,
775
+ `pnpm-workspace.yaml`) and `--filter` path globs keep the package
776
+ manager's grammar, class included — all but extglob (`packages/!(x)`),
777
+ which a member glob refuses by name: `Bun.Glob` cannot read it, and
778
+ list the exclusion as its own `!packages/x` entry instead.
779
+
780
+ ```ts
781
+ files: ['**/*'] // all project files
782
+ files: ['src/**', '!**/*.test.ts'] // narrow with exclusion
783
+ files: [] // no file inputs at all
784
+ files: ['src/**', 'tsconfig.json', 'package.json'] // specific paths
785
+ files: ['src', 'package.json'] // a directory literal is its tree
786
+ files: ['app/[id]/**'] // a bracket is literal: the route dir `[id]`
787
+ ```
788
+
789
+ Empty array is valid — the cache key still incorporates command, env,
790
+ upstream hashes, workspace fingerprint, and the project's
791
+ package.json. A task with no _file_ inputs (e.g. a pure
792
+ "download-and-verify") still cache-misses on a lockfile change.
793
+
794
+ Always applied to every glob pass (regardless of what you wrote):
795
+
796
+ - **gitignore filter** — workspace-root + project `.gitignore`.
797
+ - **Always-ignored** — `.git/**`, `.vx/**`, `*.tsbuildinfo`,
798
+ `vx-lock.json`, `*.bun-build`, and Bun's cross-compile extraction
799
+ directory `.<16 hex>-<8 hex>.tmp/**`, and vx's own restore temp
800
+ `.vx-tmp-*`, at any depth.
801
+ - **Installs** — an untracked file under any `node_modules/` (ignored or
802
+ not). A file git TRACKS there, such as a committed test fixture, is an
803
+ input like any other.
804
+ - **Declared `outputs.files`** are excluded — a task never invalidates
805
+ itself via its own output. A path an output `!` entry takes back is
806
+ no output, so it stays an input (A-44). Another task's outputs are
807
+ not excluded for you: an input glob that can match them is refused
808
+ while `rules.upfrontKeys` is on (X-54). Write the exclusion,
809
+ `['**/*', '!dist/**']`. An input entry the task's OWN outputs take
810
+ back whole is always refused (X-55): vx removes outputs before a run,
811
+ so a formatter declaring `src/**` as both would delete its sources. A
812
+ task that rewrites files in place declares no outputs.
813
+ - **Nested-project subtree** — files belonging to a project rooted
814
+ inside this one's dir are excluded. No cross-project leakage via
815
+ globs; the only cross-project relationship is `dependsOn` +
816
+ upstream-hash propagation.
817
+
818
+ ##### `inputs.workspaceFiles` (optional, default `[]`)
819
+
820
+ Workspace-root-relative globs — the Turbo `$TURBO_ROOT$` / Nx
821
+ `{workspaceRoot}` equivalent, for inputs that live outside the project
822
+ dir (a root `tsconfig.base.json`, shared codegen output, …). Same
823
+ syntax as `files` (`!` negation subtracts, in any order), same git-aware
824
+ resolution (tracked + untracked-not-ignored; gitignored files are
825
+ invisible), and the resolved paths join the same cache-key file list.
826
+
827
+ ```ts
828
+ inputs: {
829
+ files: ['src/**'],
830
+ workspaceFiles: ['tsconfig.base.json', 'shared/**', '!shared/README.md'],
831
+ }
832
+ ```
833
+
834
+ **No boundary rule — deliberate escape hatch.** Unlike `files`,
835
+ `workspaceFiles` globs may match files anywhere in the workspace,
836
+ including inside other projects' directories. That's bad practice
837
+ (prefer project-relative declarations plus `dependsOn` + upstream-hash
838
+ propagation for cross-project relationships), but it is there for the
839
+ cases that genuinely need root-anchored inputs. The hard
840
+ project-boundary rule continues to apply to project-relative `files`
841
+ globs only.
842
+
843
+ No input or output glob, `files` or `workspaceFiles`, may be absolute
844
+ or hold a `..` segment, and a brace arm counts: `{../shared,src}/**` and
845
+ `{/etc,src}/*` are refused at load, where the glob engine would have
846
+ matched nothing under that arm and said so nowhere. A Windows spelling
847
+ is refused the same way, with the forward-slash glob to write: a
848
+ backslash separator (`src\**`, `src\*.ts`) or a drive (`C:\src\**`,
849
+ `C:/src/**`). A backslash before a bracket, a brace, a `!` or a
850
+ backslash stays an escape.
851
+
852
+ Still applied: the always-ignored set (`.git/**`, `.vx/**`,
853
+ `*.tsbuildinfo`, `vx-lock.json`, `*.bun-build`, `.<16 hex>-<8 hex>.tmp/**`, `.vx-tmp-*`),
854
+ untracked files under `node_modules/`, and the task's own declared
855
+ outputs, `outputs.workspaceFiles` and the `outputs.files` its globs reach
856
+ in its own project (a task never invalidates itself).
857
+
858
+ `vx watch`: when any config declares `inputs.workspaceFiles`, the loop
859
+ watches the workspace root recursively (any file can be an input once
860
+ boundaries are off), so root-subdir changes re-trigger cycles like any
861
+ other edit. The always-ignored set still filters the noise.
862
+
863
+ ##### `inputs.env` (optional, default `[]`)
864
+
865
+ Env var names whose host values are folded into the cache key.
866
+ **Independent of `exec.env`:**
867
+
868
+ - Listing a name in `exec.env.passThrough` does NOT make its value
869
+ affect the cache.
870
+ - Listing a name here does NOT forward it to the child process.
871
+
872
+ To both forward AND track, list it in both places. Yes, the
873
+ double-declaration is mild noise; a preset helper
874
+ (`envTracked('NODE_ENV')`) can sugar it.
875
+
876
+ Names are exact: a wildcard (`NEXT_PUBLIC_*`) is refused, and so is
877
+ Turbo's `!NAME` exclusion, here, in `exec.env.passThrough` and in
878
+ `exec.env.secret` (D-53). With no wildcard to take it out of, `!NAME`
879
+ was a variable of that name, and nothing was excluded.
880
+
881
+ ```ts
882
+ inputs: {
883
+ files: ['src/**'],
884
+ env: ['NODE_ENV', 'TARGET_BROWSER'], // changes here bust the cache
885
+ }
886
+ ```
887
+
888
+ A cache-input env var the task actually reads must ALSO appear in
889
+ `exec.env.passThrough` (or `define`) — the child environment is
890
+ isolated, so without it the key varies on a value the command never
891
+ sees. Tracking-only declarations (an env var that influences inputs
892
+ some other way) are legal but rare; if in doubt, declare both.
893
+
894
+ An unset name, a name set to the empty string, and a name never
895
+ listed are three different keys: the key holds the count of names,
896
+ the names, and each value, with "unset" folded apart from every value
897
+ (the child tells unset from empty too — `process.env.X ?? 'dflt'`,
898
+ `${X-dflt}`). A name holding a NUL is refused.
899
+
900
+ ##### `inputs.runtime` (optional, default `[]`)
901
+
902
+ Shell commands whose **trimmed stdout and stderr, kept apart,** are folded
903
+ into the cache key — the runtime-output analog of `inputs.env`. It runs
904
+ in the project dir; the Nx
905
+ [`runtime` input](https://nx.dev/recipes/running-tasks/configure-inputs),
906
+ which Nx runs at the workspace root, is `workspaceRuntime` below (Turbo
907
+ has no built-in for this — see
908
+ [vercel/turborepo#4124](https://github.com/vercel/turborepo/issues/4124)).
909
+ Use it for tool/runtime versions, OS info, or a project-local probe
910
+ script whose value should bust the cache when it changes.
911
+
912
+ ```ts
913
+ inputs: {
914
+ files: ['src/**'],
915
+ runtime: ['./scripts/probe.sh', 'rustc --version'],
916
+ }
917
+ ```
918
+
919
+ Semantics:
920
+
921
+ - Each command runs in the **project dir** (cwd = the project's
922
+ directory), via `sh -c`, so pipelines and redirects work ("shell is
923
+ the API"). Deduped per `(projectDir, command)` within a run — a
924
+ command declared by both `build` and `test` in the same project runs
925
+ once.
926
+ - The **output is resolved live at hash time on every run** (inside
927
+ `resolveInputs`), exactly like `inputs.env` reads host env values
928
+ live. `vx lock` freezes the command _strings_ (they're in the
929
+ resolved config), not their output, so the field stays correct under
930
+ `vx run --frozen` — the changed output of a frozen command still
931
+ busts the cache.
932
+ - A **non-zero exit fails the run** (a hard `UserError` naming the
933
+ command and exit code) — fail-loud, like a missing git binary. A
934
+ flaky probe should not silently degrade to a stale hit.
935
+ - **Output that is not UTF-8 fails the run** too: a lossy decode keyed
936
+ every invalid byte alike. Pipe binary output through a hash or `od`.
937
+ - The command **inherits vx's full environment**, _not_ the isolated
938
+ env that task `exec` commands get — `exec.env.define` and
939
+ `passThrough` describe the command's environment, not the probe's.
940
+ This is deliberate — `node -v` / `rustc --version` need the real
941
+ `PATH` and toolchain env, and it is what lets two tasks sharing a
942
+ probe share one spawn (the per-run dedup above). The flip side (same
943
+ as `inputs.env`): an env var that differs between machines silently
944
+ changes the key; declare such inputs explicitly if you want them
945
+ visible.
946
+ - Its `PATH` does lead with the **same `node_modules/.bin` directories
947
+ the task's does** (the project's, then the workspace root's), so
948
+ `tsc --version` keys the `tsc` the task runs. Before item 996 it keyed
949
+ the global one, or failed with 127 where only the workspace had it, and
950
+ a changed local tool replayed the old output.
951
+ - The command **runs whenever a task's key is derived** — that's every
952
+ run (warm runs included, since the key decides hit vs miss), plus
953
+ `vx run --dry` / `--graph` (which predict the key) and `vx run
954
+ --no-cache` (which still derives the key). Keep runtime commands pure
955
+ probes with no side effects.
956
+ - The command runs as part of **hash derivation, before** the task's
957
+ `exec` — so it is **not** constrained by the task's own `sandbox`
958
+ policy (the probe runs unsandboxed even on a sandboxed task).
959
+
960
+ ##### `inputs.workspaceRuntime` (optional, default `[]`)
961
+
962
+ Like `runtime`, but commands run at the **workspace root** (cwd = the
963
+ workspace root) and are deduped **globally per command** across the
964
+ whole run — a `node -v` declared in 500 projects spawns exactly once.
965
+ The runtime-input analog of `workspaceFiles`: per-task, root-anchored.
966
+ Use it for global tool versions, OS info, or any probe whose value is
967
+ the same for every project. Its `PATH` leads with the workspace root's
968
+ `node_modules/.bin` only (no project's: the value is shared by all).
969
+
970
+ ```ts
971
+ inputs: {
972
+ files: ['src/**'],
973
+ workspaceRuntime: ['node -v'], // runs once per run, root cwd
974
+ }
975
+ ```
976
+
977
+ Same `sh -c` execution, live-at-hash-time resolution (frozen-safe), and
978
+ non-zero-exit-fails semantics as `runtime`. The two fields are folded
979
+ into the cache key in **distinct namespaces**, so an identical
980
+ `(command, output)` pair never aliases between them.
981
+
982
+ ##### `inputs.tasks` (optional, default = all upstream)
983
+
984
+ Same micro-syntax as `dependsOn`, plus two filter-only extras:
985
+
986
+ | Form | Meaning |
987
+ | ------------ | ---------------------------------------------- |
988
+ | `'*'` | Include every same-project upstream hash. |
989
+ | `'^*'` | Include every dep-workspace upstream hash. |
990
+ | `'name'` | Include same-project task `name`. |
991
+ | `'^name'` | Include `name` in every dep workspace. |
992
+ | `'pkg#name'` | Include the specific package's `name` task. |
993
+ | `'!<form>'` | Exclude — any of the above with a leading `!`. |
994
+
995
+ **Patterns** work here too, in EITHER half of every form:
996
+ `'build.*'`, `'^build.*'`, `'pkg#build.*'`, `'@acme/*#build'`,
997
+ `'!codegen.*'` — the same `*`-glob `dependsOn` patterns use. A filter
998
+ only ever selects from upstreams that already exist, so a package
999
+ pattern is unambiguous here, unlike `dependsOn`, which must
1000
+ materialize concrete edges and rejects it. (A filter that matched
1001
+ patterns literally would silently select zero hashes and decouple the
1002
+ task from its dependencies — a stale-hit trap.)
1003
+
1004
+ Patterns are applied in order; **last write wins**. So
1005
+ `['*', '^*', '!^noisy']` reads as "all upstream except deps' noisy
1006
+ task". Defaults:
1007
+
1008
+ - **Omitted** → all upstream contribute (`['*', '^*']`). Most common.
1009
+ - **`[]`** → fully decoupled; no upstream contributes.
1010
+
1011
+ An **exact** entry (`'codegen'`, `'^build'`, `'lib#build'`) must be
1012
+ named by some `dependsOn` entry of the same task — exactly, or by that
1013
+ entry's own `*` pattern (`dependsOn: ['build.*']` names
1014
+ `tasks: ['build.bun']`), and in a form that reaches the same task:
1015
+ `build` is this project's, `^build` only the dependencies', so
1016
+ `tasks: ['build']` against `dependsOn: ['^build']` names nothing. A
1017
+ `pkg#task` entry may be this project or a dependency, so it pairs with
1018
+ either form; two `pkg#task` entries must agree on the project. One that
1019
+ is not named (`['buidl']`) is refused at load: it would match nothing at hash time and fold no upstream hash,
1020
+ decoupling the task from its dependencies with no diagnostic — a stale
1021
+ hit waiting for the next upstream change. Patterns, wildcards and
1022
+ negations stay silent (a preset-spread pattern legitimately matches
1023
+ nothing in some projects); `[]` is the explicit way to decouple.
1024
+
1025
+ A filter selects among the task's own dependencies, never through a
1026
+ group: over `dependsOn: ['build']`, where `build` is a group of
1027
+ `build.a` and `build.b`, the filter sees `build`, not its members. A
1028
+ pattern that matches no dependency but does match a group's member
1029
+ (`'build.*'` there) is refused when the run plans: it would select
1030
+ nothing and stop the key moving with that member. Name the group, or
1031
+ depend on the member directly.
1032
+
1033
+ Examples:
1034
+
1035
+ ```ts
1036
+ tasks: ['^build'] // only ^build from deps; nothing from self
1037
+ tasks: ['codegen', '^*'] // self.codegen + everything from deps
1038
+ tasks: ['*', '^*', '!^noisy'] // all upstream except deps.noisy
1039
+ tasks: ['lib#build'] // a single cross-project hash
1040
+ tasks: [] // fully decoupled
1041
+ ```
1042
+
1043
+ **When to use this:** when a task `dependsOn`s another for ordering
1044
+ (it has to run after) but the upstream's outputs do NOT affect this
1045
+ task's outputs. Typical case: an integration-test task `dependsOn`s
1046
+ `build` for ordering, but its inputs are only the test source — so
1047
+ `inputs.tasks: []` to keep `build` from invalidating it.
1048
+
1049
+ #### `CacheOutputs`
1050
+
1051
+ ```ts
1052
+ interface CacheOutputs {
1053
+ files: readonly string[] // required
1054
+ workspaceFiles?: readonly string[] // optional; workspace-root-relative
1055
+ }
1056
+ ```
1057
+
1058
+ Project-relative globs of files the task produces. Captured on cache
1059
+ write, restored on cache hit (overwriting any local modifications),
1060
+ and **wiped before exec AND before restore** so the project dir ends
1061
+ every run bit-identical to the cached snapshot. A literal entry names
1062
+ a file or a whole directory tree: `dist` and `dist/` are `dist/**`
1063
+ (the turbo.json shape `"outputs": ["dist"]` migrates as it is); a
1064
+ leading `./` is accepted and dropped; `.` alone is refused.
1065
+
1066
+ ```ts
1067
+ outputs: {
1068
+ files: ['dist/**']
1069
+ } // typical build output
1070
+ outputs: {
1071
+ files: []
1072
+ } // tasks with no file output (lint, test)
1073
+ outputs: {
1074
+ files: ['dist/**', 'coverage/**']
1075
+ }
1076
+ ```
1077
+
1078
+ Outputs are **NOT** filtered through gitignore — typical artifact
1079
+ dirs like `dist/`, `coverage/`, `.next/`, `pkg/` are captured normally
1080
+ even when gitignored (they usually are).
1081
+
1082
+ A **symlink** the globs match is an output: it is captured as its
1083
+ target's bytes and restored as a regular file, and the clean unlinks
1084
+ it (never following it). An output DIRECTORY that is a symlink
1085
+ (`dist -> real-out`) is followed: its target is cleaned, saved and
1086
+ restored as the output; one that resolves outside the project refuses
1087
+ the task, naming the link. A link to a directory, a dangling one, or
1088
+ one whose target is outside the project cannot be stored — the save refuses it by name and caches nothing, so
1089
+ the next run executes again. A link to another output of the same task
1090
+ is stored wherever it is: `gen/latest -> v2.txt` under a
1091
+ `workspaceFiles` output caches (A-53). The clean also prunes the directories it
1092
+ emptied, so an output that is a directory one run and a file the next
1093
+ restores either way.
1094
+
1095
+ Empty `[]` is valid for tasks that produce no files (e.g. `lint`,
1096
+ `typecheck`, `test`); you still cache the no-op success so the next
1097
+ run is a no-op too.
1098
+
1099
+ A **`!` entry takes a path back** from what the positive globs select
1100
+ (A-44; a tracked fixture and a scratch dir under `dist` stay out of
1101
+ the clean this way):
1102
+
1103
+ ```ts
1104
+ outputs: {
1105
+ files: ['dist/**', '!dist/fixture.html', '!**/*.tmp']
1106
+ }
1107
+ ```
1108
+
1109
+ A path taken back is not cleaned before a run or a restore, not saved,
1110
+ not accepted from a remote artifact, and not hidden from `vx watch`;
1111
+ it stays an input, so an edit to it moves the key. A list of only `!`
1112
+ entries selects nothing and is refused, and `!!x` is refused as it is
1113
+ for inputs. The directory short-circuit on a warm hit reads the
1114
+ positive globs, so a task with a `!` entry keeps it (A-46).
1115
+ `workspaceFiles` takes `!` the same way.
1116
+
1117
+ **Cleaning semantics** (one of vx's strict-output-ownership rules):
1118
+
1119
+ - Before exec on a cache miss: the declared output globs are resolved
1120
+ and matching files / dirs are wiped. So a leftover `dist/old.js`
1121
+ from a prior build can't survive into a fresh build that doesn't
1122
+ rewrite it.
1123
+ - Before restore on a cache hit: same wipe, then files are copied
1124
+ from the cache entry into the project dir. The post-restore tree
1125
+ is the cached snapshot, byte-for-byte.
1126
+
1127
+ Skipped when `cache.outputs.files` is empty (nothing declared as
1128
+ output) and when no cache-write axis is enabled (e.g. `--no-cache`,
1129
+ or a read-only `--cache=local:r`) — the user is debugging and
1130
+ managing the tree themselves. `--force` keeps writes on, so it does
1131
+ clean.
1132
+
1133
+ ##### `outputs.workspaceFiles` (optional, default `[]`)
1134
+
1135
+ Workspace-root-relative globs for outputs the task writes OUTSIDE its
1136
+ project dir (e.g. a root-level generated file). Same capture / restore
1137
+ / wipe semantics as `files`, anchored at the workspace root; packed
1138
+ into the artifact under a separate `workspace-outputs/<rel-to-root>`
1139
+ namespace so project and workspace outputs never collide. A project
1140
+ `outputs.files` glob under a top-level `workspace-outputs/` is refused
1141
+ at load: that name is the namespace. A file another glob matches there
1142
+ (`**/*.js`) is refused at save, and the task is not cached.
1143
+
1144
+ ```ts
1145
+ outputs: {
1146
+ files: ['out.txt'],
1147
+ workspaceFiles: ['generated/**'],
1148
+ }
1149
+ ```
1150
+
1151
+ **No boundary rule — deliberate escape hatch.** These globs may
1152
+ capture (and on restore, overwrite; on clean, wipe) files anywhere in
1153
+ the workspace, including inside other projects' dirs. Prefer
1154
+ project-relative `files` whenever the task can write inside its own
1155
+ dir. Two tasks whose workspace outputs provably overlap (equal
1156
+ literals, or a literal a glob matches) are refused at graph build, like
1157
+ overlapping `files`: vx cleans declared outputs before a run and before
1158
+ a restore, so the second would delete the first's. The comparison is by
1159
+ PATH, not by spelling — `./dist/**` and `dist/**` are one declaration,
1160
+ and so are `dist//**` and `dist/./app.js` (item 441) — and a literal
1161
+ entry is read as the file OR its whole tree, the same rule the resolver
1162
+ uses, so `dist` collides with `dist/app.js` (item 442). A whole subtree
1163
+ collides with any glob whose literal head lies inside it: `dist/**` with
1164
+ `dist/extra/**` or `dist/*.js` (item 941). Globs that only _might_
1165
+ overlap (`dist/*` and `dist/sub/**`) are let through; there, last
1166
+ restore wins. A workspace output is also compared with every other
1167
+ project's `files` outputs, read from the root: `packages/b/dist/a.txt`
1168
+ collides with `b`'s `dist/**` (item 1088). An overlap
1169
+ between two tasks one of which depends on the other is refused too,
1170
+ unless the workspace sets `rules: { exclusiveOutputs: false }` (X-53);
1171
+ then the dependant is additive and owns only what its run adds to the
1172
+ tree (`caching.md` § Additive outputs, item 588). Across the two namespaces
1173
+ an upstream `files` task is told the dependant's globs in its own
1174
+ project's terms, so a workspace glob that does not start inside that
1175
+ project (`**/a.txt`) is refused even with the edge.
1176
+
1177
+ ### `exec.sandbox` (optional)
1178
+
1179
+ Opt this command into an OS-level sandbox. A persistent task runs
1180
+ inside it too — enforced, but with no violation report, since the report
1181
+ reads the trace after the child exits and a server exits only at
1182
+ teardown. **Opt-in per task — omit it and the command runs
1183
+ unsandboxed.** Full walkthrough in the
1184
+ [sandboxing guide](https://vznjs.github.io/vx/guides/sandboxing/).
1185
+
1186
+ ```ts
1187
+ interface SandboxConfig {
1188
+ allow?: SandboxGrants // what the command may do
1189
+ deny?: { network?: string[] } // domains to refuse, run-wide (below)
1190
+ ignore?: SandboxIgnore // violations to leave out of the report
1191
+ weakerWhenNested?: boolean // Linux: let a sandboxed task sandbox (default false)
1192
+ weakerNetworkIsolation?: boolean // macOS: host-proxy net, lower isolation (default false)
1193
+ }
1194
+
1195
+ interface SandboxGrants {
1196
+ read?: readonly string[] // paths or globs, project-relative or absolute
1197
+ write?: readonly string[] // paths or globs; a write grant is readable too
1198
+ network?: true | readonly string[] // an allowlist of domains; `true` adds none (below)
1199
+ systemInfo?: readonly string[] // sysctl names, e.g. 'vfs.disk-space'; each grants system-info and sysctl-read (macOS)
1200
+ unixSockets?: true | readonly string[] // AF_UNIX bind/connect, all or by path (Linux: any path)
1201
+ localBinding?: boolean | readonly number[] // bind and reach localhost ports (macOS; Linux needs no grant); a list also exposes them to the host (a port the host or another running task already holds fails the task)
1202
+ machLookup?: readonly string[] // mach global-names (macOS)
1203
+ pty?: boolean // acquire a TTY
1204
+ gitConfig?: boolean // write the repository's .git/config (this task only)
1205
+ }
1206
+
1207
+ interface SandboxIgnore {
1208
+ read?: readonly string[] // denied reads to leave out of the report
1209
+ write?: readonly string[]
1210
+ systemInfo?: readonly string[]
1211
+ network?: readonly string[] // '<host>:<port>'
1212
+ }
1213
+ ```
1214
+
1215
+ One shape describes what a task may do; vx translates it into a seatbelt
1216
+ profile on macOS and bwrap mounts plus seccomp on Linux. `ignore` takes
1217
+ patterns per class a denial is reported in — `read`, `write`,
1218
+ `systemInfo`, `network` — so a noisy probe is silenced with the grant
1219
+ that would have permitted it; any other name is refused. Every grant is
1220
+ the task's own: `unixSockets` (or a `localBinding` port list, whose
1221
+ bridge is a unix socket) lifts the `socket(AF_UNIX)` block for the task
1222
+ that declares it, never for the run's other sandboxed tasks. On Linux
1223
+ the block is the kernel's answer to the call itself, so it is reported
1224
+ nowhere: the task reads only its tool's own `socket(1, 1, 0): Operation
1225
+ not permitted` (a Docker, ssh-agent or database socket), and the grant
1226
+ is `unixSockets`:
1227
+
1228
+ ```ts
1229
+ exec: {
1230
+ command: 'bun build --compile --target=bun-linux-arm64 src/bin.ts --outfile dist/vx',
1231
+ sandbox: {
1232
+ allow: {
1233
+ read: ['.'],
1234
+ // A cross-compile target the Bun cache lacks is fetched from npm,
1235
+ // extracted into `<cwd>/.<hash>-00000000.tmp/` and moved into the cache.
1236
+ write: ['dist/vx', '~/.bun/install/cache/', '.*.tmp/**'],
1237
+ network: ['registry.npmjs.org'],
1238
+ systemInfo: ['vfs.disk-space'],
1239
+ },
1240
+ ignore: { write: ['*.bun-build'] },
1241
+ },
1242
+ }
1243
+ ```
1244
+
1245
+ **Globs.** `read` and `write` accept patterns. On macOS the pattern
1246
+ reaches the policy and matches files created during the run; on Linux a
1247
+ grant is a mount, so the pattern is expanded when the task starts and a
1248
+ file created later is not covered — grant its directory instead. On both
1249
+ platforms `<dir>/**` and `<dir>/**/*` collapse to `<dir>`, so
1250
+ `read: ['**/*']` lets a task list its own cwd; a `<dir>` that is itself a
1251
+ glob keeps its subtree (`.*.tmp/**` covers what is inside each match).
1252
+ Unlike a task glob, a grant keeps `Bun.Glob`'s brackets: `[id]` is a
1253
+ class, so a Next.js route is granted escaped, `read: ['pages/\\[id\\].tsx']`.
1254
+ On Linux a WRITE path holding a bracket, `*` or `?` cannot be mounted
1255
+ (the runtime drops it): vx says so once, names the directory above it to
1256
+ grant instead, and a write under it is refused and reported. A READ path
1257
+ whose name holds `*` or `?` (granted escaped, `a\\*b.txt`) cannot be
1258
+ granted alone either — the runtime would grant its siblings too — so vx
1259
+ leaves it out, says so once, and a read of it is refused and reported.
1260
+ A Linux grant of either kind holding a backslash is left out the same
1261
+ way: Bun's `realpath` refuses such a path, and the runtime mounts none
1262
+ it cannot resolve.
1263
+
1264
+ A Linux WRITE grant that matches nothing when the task starts therefore
1265
+ mounts nothing. Where a read grant mounts its directory, the task's first
1266
+ write under it fails with `Read-only file system` — a message naming
1267
+ neither vx nor the grant — so vx reports that grant itself before the
1268
+ task runs, once, and names the directory to grant instead, spelled as
1269
+ the config spells it (`allow: { write: ['gen/'] }`). Where no mount
1270
+ holds the directory, it is the sandbox's own scratch, the one mask left
1271
+ writable: the task may create, write and remove what the glob matches
1272
+ (anything else it writes there too), and nothing it leaves there
1273
+ outlives the task. That is right for a tool's temp directory and
1274
+ wrong for an output. A read grant matching nothing is ordinary
1275
+ (an optional file, a cache not yet populated) and is not reported. A
1276
+ pattern under a directory that does not exist yet matches nothing the
1277
+ same way, where the scan once failed the task with a bare `ENOENT` (B-6).
1278
+
1279
+ **A write grant's shape.** A write path that does not exist yet is
1280
+ created before the task starts (a mount needs something to bind), and a
1281
+ literal names a FILE: `write: ['dist/vx']` is an empty `dist/vx` the
1282
+ build overwrites. A directory the task will create is spelled with a
1283
+ trailing slash — `write: ['coverage/']` — or as a glob (`'dist/**'`);
1284
+ outside the project (`'~/.bun/install/cache/'`) only these two shapes are
1285
+ created, never a file.
1286
+ Spell a directory as a bare literal and the task's own `mkdir` meets
1287
+ "File exists" ("Not a directory" for a path inside it, `mkdir -p
1288
+ coverage/lcov`); the failure then says so, names the `dir/` spelling, and
1289
+ vx removes the empty file it made (it takes back any placeholder the
1290
+ task never wrote, so an unwritten one is never archived as an output).
1291
+ A grant that leaves the project through a symlink is refused: the grant
1292
+ binds the path it names, and vx follows no link out of the project. So
1293
+ is one whose bind would make `.git`, `.vx`, the cache directory or a
1294
+ nested project writable:
1295
+ a file grant at a single-package workspace's root binds the root. A
1296
+ directory grant is judged as the directory, made before the judgement
1297
+ when it does not exist yet, so `dist/` or `dist/**` at that root stands.
1298
+
1299
+ **A write grant is readable, and on Linux it reads WIDER than it looks.**
1300
+ A write path is readable too (`tsc --incremental` re-reads its own
1301
+ `.tsbuildinfo`). On Linux a grant is a mount and bwrap cannot rename onto
1302
+ an active file mount — every atomic writer stages beside its target and
1303
+ renames — so a FILE-shaped grant is bound as its DIRECTORY. That
1304
+ directory is then readable AND writable in full: with
1305
+ `write: ['out.txt']` in the project root, every file in the project root
1306
+ can be read. Such a read succeeds, so there is no denial; vx reports it
1307
+ from the trace instead, as a violation: a read of anything that was in
1308
+ that directory when the task started and that no grant covers, the
1309
+ directory's listing included. The declared file and what the task made
1310
+ there itself stay readable. Put declared outputs in a subdirectory and
1311
+ the rest stays denied outright. macOS matches paths rather than
1312
+ mounting, so a file grant stays exact there. Pinned in
1313
+ `tests/sandbox-runtime.unsafe.test.ts` (2026-09-20) and
1314
+ `tests/sandbox-widened-reads.unsafe.test.ts`.
1315
+
1316
+ **A `network` entry is a host pattern**: `example.com`, `*.example.com`,
1317
+ either with a port (`example.com:443`), or `localhost`. A scheme or path
1318
+ (`https://example.com`), a dotless host, a bad port, and `*` or `*.com`
1319
+ (too broad) refuse the run with the entry named; `deny.network` also
1320
+ takes a bare `*` (deny all, `*:22` for one port). Until 2026-10-02 such an
1321
+ entry matched nothing with no word, and an allowed `*` opened every host
1322
+ to every sandboxed task of the run.
1323
+
1324
+ **`network` is per-RUN, not per-task.** SRT runs one filtering proxy
1325
+ per `vx run` and checks every request against the allowlist that proxy
1326
+ was started with: the union of every domain list any sandboxed task in
1327
+ the graph declared. Every sandboxed task is handed that proxy. So a task
1328
+ that declares no network reaches the domains another task of the run
1329
+ listed, and `network: true` reaches only those (nothing in a run with no
1330
+ list). `deny.network` is the run's too: the proxy starts with the union
1331
+ of every task's denies and refuses those domains to every task, checked
1332
+ before the allowlist (B-21). A refused request is a violation on both
1333
+ platforms, `deny network-outbound <host>:<port> (<reason>)` from the
1334
+ proxy, and fails the task even when it survived the refusal;
1335
+ `ignore: { network: ['<host>:<port>'] }` silences one, matched as the
1336
+ proxy matches: case-blind, a trailing dot dropped (until 2026-10-08 the
1337
+ client's spelling, `EXAMPLE.Com` or `example.com.`, escaped it). Until
1338
+ 2026-10-02 Linux reported none, and the line could not be ignored on macOS.
1339
+
1340
+ **Baseline** (`sandbox: {}`): the task reads nothing in the workspace,
1341
+ writes nothing but its own `TMPDIR` and reaches no domain no task of the run lists — not even its own project
1342
+ directory, which is why `allow: { read: ['.'] }` is the first line of
1343
+ almost every real block. The task still starts in its own directory, an empty one
1344
+ then: a relative read is refused and reported, never resolved elsewhere. The read wall is the WORKSPACE ROOT: a path
1345
+ outside it (`~/.cache`, `/etc`, the toolchain) is readable and folds into
1346
+ no key, so a task whose output depends on one declares it as a key input
1347
+ (`inputs.runtime`, `inputs.env`) — the sandbox does not catch it (item
1348
+ 966).
1349
+ The one exception is where tools keep credentials, denied unless a
1350
+ grant names one (`read: ['.', '~/.npmrc']` for a publish; a write grant at
1351
+ or inside a store frees that path alone), since a
1352
+ dependency the task ran could copy a key into an output the cache
1353
+ shares (L-41): `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`,
1354
+ `~/.config/gcloud`, `~/.config/gh`, `~/.docker/config.json`, `~/.netrc`,
1355
+ `~/.git-credentials`, `~/.npmrc`, `~/.yarnrc.yml`, `~/.pypirc`,
1356
+ `~/.bunfig.toml`, `~/.config/.bunfig.toml`, `~/.cargo/credentials`,
1357
+ `~/.cargo/credentials.toml`, `~/.gem/credentials`, `~/.config/hub`,
1358
+ `~/.config/containers/auth.json`, `~/.terraform.d/credentials.tfrc.json`,
1359
+ `~/.vault-token`, `~/.pgpass`.
1360
+ What it grants from there is the union of the read grants and, on Linux,
1361
+ the DIRECTORY holding each file-shaped write grant (above).
1362
+ Nothing is inherited from `cache` — `cache.inputs` says what INVALIDATES a task, `sandbox.allow`
1363
+ says what it may TOUCH, and deriving one from the other made a
1364
+ declaration added for caching silently widen the sandbox. The one grant
1365
+ vx makes for you is dependencies: `node_modules` and, through it, the
1366
+ real path of every workspace package linked there, and in turn in each
1367
+ such package's own `node_modules`. A project never names
1368
+ a sibling to import what its `package.json` already depends on. A link
1369
+ back to the task's own project, or to a directory holding it, is not
1370
+ followed: npm and Yarn link every workspace package at the root, the
1371
+ task's own included, and following it would grant the whole project
1372
+ past its `allow.read`.
1373
+
1374
+ A task that declares `cache` gets a linked workspace package only when
1375
+ its key already answers for it: some task of that package is folded into
1376
+ the key through `dependsOn` (as `cache.inputs.tasks` selects it, groups
1377
+ passing everything through). Every other link inside the workspace root
1378
+ is withheld, and a read through one fails the task with the denial plus
1379
+ one line naming the package and the edge that would key it. Declaring
1380
+ `cache` can only narrow this grant, never widen one: a task with no
1381
+ `cache` keeps it whole, having no key to be stale, and your own `allow`
1382
+ grants never depend on it. The key answers for the package, not the
1383
+ file: an edge to `ui#source` (inputs `src/**`) covers a read of
1384
+ `ui/README.md` too.
1385
+
1386
+ **A missing write grant fails the task.** On macOS seatbelt refuses the
1387
+ write and reports it. On Linux the write meets a read-only bind or a
1388
+ read-only mask: the empty directory the sandbox lays over what it hides
1389
+ (the workspace root around a project, a single-package workspace's root,
1390
+ the project root around a write grant punched out of a read grant —
1391
+ `read: ['.']` with `write: ['dist/']` binds the children one by one). It
1392
+ is `Read-only file system` on both; until 2026-10-02 the Linux mask was
1393
+ writable, and a write there succeeded and left nothing on disk. The
1394
+ runtime's write observer saw the attempt, and a write no grant binds is
1395
+ reported and fails the task, even when the command swallowed the error
1396
+ and exited 0 (B-5; before it, both Linux shapes passed with nothing
1397
+ reported, items 444 and 1011). A write outside the project is refused
1398
+ the same way and named on a failed task, never counted. The remedy is to
1399
+ declare it: `allow: { write: [...] }`.
1400
+
1401
+ **A write grant cannot be removed on Linux.** A directory grant is
1402
+ mounted in place, so `rm -rf dist && tsc` under `write: ['dist/']`
1403
+ empties `dist` and then fails with `Read-only file system`; a failed
1404
+ task names the grant. Remove its contents instead: `rm -rf dist/*`.
1405
+
1406
+ **The boundary is the workspace root.** A task may not leave its own
1407
+ project, so every sibling project and every root file is denied. Being
1408
+ stopped at that wall is the sandbox working, not a finding: only
1409
+ denials INSIDE the project are reported, because those are the reads
1410
+ that make a cache key wrong. A write refused past the wall is named
1411
+ beside a FAILED task, never counted, with the directory to grant, spelled
1412
+ from the project when it is in the workspace (under the host's temp
1413
+ directory, `$TMPDIR`, the task's own, instead): a
1414
+ tool that cannot fill its cache (`~/.bun/install/cache`) rarely says
1415
+ where it tried. So is a read the wall hid of a path that exists on the
1416
+ host, with the grant spelled from the project (`'../../tsconfig.base.json'`):
1417
+ the tool said only "not found". To reach a path outside the project but
1418
+ inside the workspace — a workspace-level fixture — declare it; a path
1419
+ outside the workspace is not walled (above).
1420
+
1421
+ **git in a sandboxed task** reads the repository only if it is granted:
1422
+ `read: ['.', '../../.git']` from a project two levels down (`.git` is a
1423
+ wall, so `read: ['.']` in a root project leaves it out too). On Linux
1424
+ a task whose grants name a `.git` gets `GIT_DISCOVERY_ACROSS_FILESYSTEM=1`
1425
+ (a value the task sets wins), since every sandbox mount is a filesystem
1426
+ boundary git's discovery stops at. `git rev-parse` and `git log` then answer as outside;
1427
+ `git status` reports a file the task may not read as deleted.
1428
+
1429
+ **Policy: fail on violation.** An undeclared read, or a write the
1430
+ sandbox refuses, fails the task, and a failed task is never cached.
1431
+ On Linux a denied read is reported only where `strace` is on PATH and
1432
+ may attach; elsewhere the sandbox still denies it, the task fails
1433
+ only if the command does, and the run says once that reads go unreported. Activation is lazy (only when
1434
+ some task declares `exec.sandbox`); on an unsupported platform a
1435
+ sandboxed task fails fast rather than running unsandboxed. Linux needs
1436
+ `bubblewrap`, `socat` and `ripgrep` installed (the runtime expands its
1437
+ mandatory deny globs with `rg`); a missing one is named with the install.
1438
+ Inside a write grant, a file named like a shell or tool config
1439
+ (`.bashrc`, `.zshrc`, `.gitconfig`, `.mcp.json`, …), anything under
1440
+ `.vscode/` or `.idea/`, and `.git/hooks` or `.git/config` stay read-only
1441
+ to the task, down to three levels below the workspace root, ignored by
1442
+ git or not.
1443
+
1444
+ **No `[`, `]`, `*` or `?` in the project's path.** The runtime reads a
1445
+ path holding one as a pattern: on Linux it mounts no write path that
1446
+ does and a read grant matches its siblings, and macOS's rules compile it
1447
+ as a pattern too. So a sandboxed task in a project under such a directory
1448
+ (`~/[old]/repo`, `~/w*s/repo`) is refused, naming it: rename the
1449
+ directory or drop `exec.sandbox`. On Linux so is a project under a
1450
+ directory holding a backslash, which the runtime cannot mount at all (its
1451
+ task saw no project and ran in `$HOME`).
1452
+
1453
+ **macOS cannot nest.** `sandbox_apply` is refused inside a sandboxed
1454
+ process, so a task that itself sandboxes something (vx's own test suite)
1455
+ cannot be sandboxed on macOS. `weakerWhenNested` covers the Linux case;
1456
+ there is no macOS equivalent to offer.
1457
+
1458
+ ## Group tasks (no `exec`)
1459
+
1460
+ A task with no `exec` and a `dependsOn` is a **group task** — a pure
1461
+ aggregator. Running a group is equivalent to running its dependencies;
1462
+ nothing else happens (no spawn, no I/O, no cache read/write). An empty
1463
+ `dependsOn: []` is an explicit no-op group: it exists to be named — by a
1464
+ dependant's `^build`, by `vx run build --all` — and runs nothing. A
1465
+ project with no `build` gets a keyed one (above), which a bare name
1466
+ does not select.
1467
+
1468
+ A group with `cache` is a **keyed group**: its `cache.inputs` are a key
1469
+ and nothing runs. A task that depends on it folds that key, so an edit
1470
+ to those inputs re-runs the dependant, and `--affected` reaches it. Its
1471
+ `cache.outputs` must be `{ files: [] }` (a group writes nothing), and it
1472
+ needs no `dependsOn`. It spawns nothing, writes no cache entry, and the
1473
+ run summary does not count it. `@vzn/vx-migrate` writes one for each
1474
+ Nx `^production`-style input and each Turbo transit node.
1475
+
1476
+ ```ts
1477
+ // `test` re-runs when a file under fixtures/ changes; nothing spawns for it
1478
+ fixtures: { cache: { inputs: { files: ['fixtures/**'] }, outputs: { files: [] } } },
1479
+ test: { exec: { command: 'bun test' }, dependsOn: ['fixtures'], cache: { /* … */ } },
1480
+ ```
1481
+
1482
+ Bare `--exclude-dependencies` keeps a group's edges for the same reason:
1483
+ `vx run ci --exclude-dependencies` runs `ci`'s members without their own
1484
+ dependencies. A name list (`--exclude-dependencies=lint.oxfmt`) drops a
1485
+ member it names, like any edge.
1486
+
1487
+ ```ts
1488
+ // `vx run install --all` → fans out to `build` in every workspace dep
1489
+ install: {
1490
+ description: 'build everything in workspace dependency order',
1491
+ dependsOn: ['^build'],
1492
+ }
1493
+
1494
+ // `vx run ci` → runs format-check + lint + test in the cwd project
1495
+ ci: {
1496
+ description: 'format-check + lint + test (CI gate)',
1497
+ dependsOn: ['format-check', 'lint', 'test'],
1498
+ }
1499
+ ```
1500
+
1501
+ Group tasks:
1502
+
1503
+ - **Are not counted in the run summary.** "3 tasks, 2 cached" reflects
1504
+ real executable tasks — including a group in the count is confusing
1505
+ ("3 of 4 cached" when one was a group that ran nothing).
1506
+ - **Are not recorded in the `runs` table.** Analytics queries that
1507
+ aggregate runtime won't see them.
1508
+ - **Render no framed block** in the live output.
1509
+ - **DO contribute a stable hash to downstream tasks** that filter
1510
+ `inputs.tasks` to include the group. The hash is rolled up from
1511
+ the group's upstream outcomes (sorted, joined, hashed). So any
1512
+ change beneath the group cascades correctly.
1513
+
1514
+ The loader rejects:
1515
+
1516
+ - A task with no `exec`, no `dependsOn` and no `cache` (literal no-op).
1517
+ - A group whose `cache.outputs` names a file (a group writes nothing).
1518
+
1519
+ ## Workspace config (`vx.workspace.ts`)
1520
+
1521
+ Loaded from `vx.workspace.{ts,mts,js,mjs,cts,cjs}` at the workspace root.
1522
+ **Optional** — when missing, every field falls back to its built-in
1523
+ default.
1524
+
1525
+ ```ts
1526
+ import { defineWorkspace } from '@vzn/vx/config'
1527
+ import { otel } from '@vzn/vx-otel'
1528
+
1529
+ export default defineWorkspace({
1530
+ concurrency: 8,
1531
+ cacheDir: 'build/.vx-cache',
1532
+ timeout: 600_000,
1533
+ cacheRetention: { olderThan: '30d', maxSize: '10G' },
1534
+ affectedBase: 'origin/main',
1535
+ cacheScope: 'trusted',
1536
+ rules: { exclusiveOutputs: true, upfrontKeys: true },
1537
+ schedule: 'most-work',
1538
+ plugins: [otel()],
1539
+ })
1540
+ ```
1541
+
1542
+ ```ts
1543
+ interface WorkspaceConfig {
1544
+ /** Maximum concurrent tasks. Defaults to the cores this process may use (the CPU count, capped by a cgroup quota). */
1545
+ concurrency?: number
1546
+ /** Cache directory, relative to workspace root. Named, it holds the whole cache; unset, entries live in the user's shared store. */
1547
+ cacheDir?: string
1548
+ /** Default per-task timeout (ms) for tasks without their own exec.timeout. */
1549
+ timeout?: number
1550
+ /** Evict from the local cache at the end of each run (the `vx cache prune` policy). */
1551
+ cacheRetention?: { olderThan?: string; maxSize?: string }
1552
+ /** The git ref a bare `--affected` compares with. */
1553
+ affectedBase?: string
1554
+ /** Where remote writes land: 'trusted' (default), 'read-only', or an untrusted scope name. */
1555
+ cacheScope?: string
1556
+ /** Graph rules checked before anything runs; each on unless set to false. */
1557
+ rules?: WorkspaceRules
1558
+ /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1559
+ schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1560
+ /** Run-level plugins (cache / executor / telemetry capabilities). */
1561
+ plugins?: readonly Plugin[]
1562
+ }
1563
+
1564
+ interface WorkspaceRules {
1565
+ /** Refuse overlapping outputs even across a dependsOn edge. Default true. */
1566
+ exclusiveOutputs?: boolean
1567
+ /** Refuse a task whose inputs can match another task's outputs. Default true. */
1568
+ upfrontKeys?: boolean
1569
+ }
1570
+ ```
1571
+
1572
+ - **`concurrency`** — used as the default cap on parallel tasks; the
1573
+ CLI `--concurrency <n>` still wins when passed.
1574
+ - **`timeout`** — the lowest-precedence default per-task timeout (ms),
1575
+ applied to any task that declares no `exec.timeout`. Precedence,
1576
+ highest first: per-task `exec.timeout` → `--timeout` /
1577
+ `RunOptions.timeout` → `VX_TASK_TIMEOUT` env → this. A runaway task's
1578
+ process group is SIGTERMed and the task reported `failed`. Purely a safety net — never folded
1579
+ into a cache key (a timed-out task fails and is never cached).
1580
+ - **`cacheDir`** — unset, the workspace keeps its index and history in
1581
+ `.vx/cache` and its entries and artifacts in its repository's shared
1582
+ store (`~/.vx/<id>/cache`), where every other checkout of the
1583
+ repository hits them (`docs/caching.md`). Named here, by
1584
+ `VX_CACHE_DIR`, or by `--cache-dir`, the directory holds the whole
1585
+ cache, shared with no other workspace.
1586
+ Relative paths are resolved against the workspace
1587
+ root, `~/` against the home directory; absolute paths are used
1588
+ as-is. The home directory itself (`~`) is refused. `vx run`, `vx cache prune`,
1589
+ and any other reader use the same resolution
1590
+ (`src/workspace/workspace.ts:resolveCacheDir`). The cache is a
1591
+ directory of its own: a first index in one that holds a
1592
+ `package.json` or `pnpm-workspace.yaml` (`''` and `'.'` name the
1593
+ root), whose subdirectory does (`'packages'`), or that holds the
1594
+ workspace (`'..'`, `'/'`) is refused before anything is written, since its `*`
1595
+ `.gitignore` would hide the sources from git and the cache keys.
1596
+ - **`cacheRetention`** — the `vx cache prune` policy, applied at the
1597
+ end of every run: entries unused for
1598
+ `olderThan` (`30d`, `12h`, `90m`, `45s`) go first, then the
1599
+ least-recently-used until the cache is under `maxSize` (`10G`,
1600
+ `500MB`, `64KB`; a bare number is refused as a typo, `10B` is not).
1601
+ Either or both, and neither may be zero: each would evict what every
1602
+ run just saved (item 969). It runs after the run's saves
1603
+ and uploads have landed, never on a run a signal or an abort stopped
1604
+ (one stopped while it waited on the workspace lock never held it), only when something is due (a run with
1605
+ nothing to evict pays one scan of the index), and says nothing:
1606
+ housekeeping prints no line (owner, 2026-10-06). Under `olderThan` an entry the run just used is never due, but `maxSize` is least-recently-used first, so a bound below one run's outputs evicts that run's own. The prune's
1607
+ orphan sweep (artifacts no index row counts, judged by file time —
1608
+ see `vx cache prune`) also runs on its own clock, at most once an
1609
+ hour, so their bytes go even when nothing the index holds is due;
1610
+ listing the directory every run would cost 0.5 ms per 1,000
1611
+ entries. Housekeeping,
1612
+ not the run's work: a failure is silent, never a failed run. Not
1613
+ folded into any cache key. Omitted → the cache grows until
1614
+ `vx cache prune`.
1615
+ - **`affectedBase`** — the git ref a bare `--affected` compares with
1616
+ (`origin/develop`); `--affected=<base>` still wins. Omitted →
1617
+ `origin/HEAD`, then the first of `origin/main`, `origin/master`, `main`, `master` that is not HEAD (D-93), else `HEAD~1`. A plugin's `config` stage may set it:
1618
+ `nx()` from `NX_BASE` or nx.json's `defaultBase`, `turbo()` from
1619
+ `TURBO_SCM_BASE`, else on GitHub Actions from `GITHUB_BASE_REF` or the
1620
+ push event's `before`, as Turbo does. Not folded into any cache key.
1621
+ - **`cacheScope`** — where this run's remote cache writes land.
1622
+ `'trusted'` reads and writes the task keys: the default branch's CI.
1623
+ `'read-only'` reads them and writes nothing: a laptop. Omitted, `vx
1624
+ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1625
+ `'trusted'` on CI or when `--cache` names the remote; `run()` takes
1626
+ `RunOptions.defaultCacheScope`, else `'trusted'`.
1627
+ Any other name (`'pr-123'`; letters, digits, `. _ - / @`, at most 128) is an untrusted scope: a remote read tries the scope's key, then
1628
+ the trusted one, and a write goes to the scope's only, so a pull request
1629
+ never writes what the default branch reads, and two scopes never see
1630
+ each other. A scope's key is derived from the task key, so every
1631
+ remote wire stores it unchanged. A clamp on `--cache`, never a
1632
+ widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1633
+ the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
1634
+ boundary: a run holding a write credential can write any key, so only
1635
+ a cache server that scopes writes by token can refuse one
1636
+ (`docs/security.md` § Cache poisoning). Not folded into any cache key.
1637
+ - **`schedule`** — which ready task starts first when more are ready
1638
+ than workers, with no timings. `most-work` (the default): the one the
1639
+ most tasks wait on, at any depth. `critical-path`: the head of the
1640
+ longest chain of tasks below it. `direct-dependents`: the one the most
1641
+ tasks wait on directly (Nx's rule). `ready-order`: the one that became
1642
+ ready first (Turbo's rule). Each loses some graph shape the others win
1643
+ (`blog/scheduling-strategies`). A `schedule` plugin's weights
1644
+ (`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
1645
+ their ties. The default costs what it did; another strategy is
1646
+ computed only when named. Any other value is refused. Never folded
1647
+ into a cache key.
1648
+ - **`rules`** — checks on the task graph, each on unless set to
1649
+ `false`. Turning one off allows a shape vx runs correctly but more
1650
+ slowly; never folded into a cache key. A value that is not a boolean,
1651
+ or a rule vx does not know, is refused.
1652
+ - **`exclusiveOutputs`** (X-53) — two tasks whose declared outputs
1653
+ overlap are refused even when a `dependsOn` edge orders them:
1654
+ `<a> and <b> both declare the output "<glob>" … Give each task its
1655
+ own output path, or set rules: { exclusiveOutputs: false } in
1656
+ vx.workspace.ts to let a dependant add to its upstream's outputs.`
1657
+ Off, the dependant adds to its upstream's tree (item 588;
1658
+ `caching.md` § Additive outputs). Two overlapping tasks with no
1659
+ edge are refused either way.
1660
+ - **`upfrontKeys`** (X-54) — a cached task whose `inputs.files` can
1661
+ match a same-project task's `outputs.files`, or whose
1662
+ `inputs.workspaceFiles` can match any task's `outputs.workspaceFiles`
1663
+ or another project's `outputs.files` read from the root, or whose
1664
+ `inputs.files` can match another task's `outputs.workspaceFiles`
1665
+ landing in its project (X-135), is refused,
1666
+ edge or no edge: `<reader> reads "<glob>" in cache.inputs.<field>,
1667
+ which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
1668
+ to <reader>'s cache.inputs.<field>, or set rules: { upfrontKeys:
1669
+ false } in vx.workspace.ts to let it wait for its producer.` Such a key
1670
+ reads what the producer writes this run, so vx cannot know it before
1671
+ the producer ran: it is not probed, prefetched or restored ahead of
1672
+ the schedule (`caching.md` § Local restore tier). The upstream's key
1673
+ already reaches the reader through `dependsOn`. A `!` entry that
1674
+ takes the whole output back clears it, and an output the reader's
1675
+ `!` entries take back no longer holds its key back either. A task's
1676
+ own outputs, a task with no `cache`, and a group are exempt. Off,
1677
+ the key waits for its producer, as before.
1678
+ - **`plugins`** — the run-level extension points. Optional: core
1679
+ applies no plugin by default, and the local executor and the local
1680
+ cache are its floor — the tail of every executor list and cache chain
1681
+ — so a workspace that declares none runs and caches here.
1682
+ Declaration order is precedence: every `executor` is consulted in
1683
+ order per task (first to accept runs it; what all decline runs
1684
+ locally); every `cache` layer is chained (lookup walks, save reaches
1685
+ all, the local store last). Each entry is a `VxPlugin` contributing
1686
+ any subset of the fourteen hooks, in pipeline order: `config` (edit
1687
+ the workspace config before anything derives from it), `discover`
1688
+ (name directories to make projects beyond the members), `project`
1689
+ (edit a loaded project's tasks — inject, remove, rewrite;
1690
+ re-validated by core and hashed into the key like a hand edit),
1691
+ `graph` (edit the task graph's edges), `key` (per-task
1692
+ `{ name: value }` material folded into the cache key), `fingerprint`
1693
+ (claim a lockfile and key it per project, or a root file the plugin's
1694
+ stages read, such as `turbo.json`, so `--affected` asks the plugin
1695
+ about it and `vx watch` re-runs on it), `schedule` (task id →
1696
+ priority, merged over the scheduler's baseline), `admit` (may this
1697
+ ready task start now beside what runs here), `executor` (return a
1698
+ `TaskExecutor` — where one task's command runs — or decline), `cache`
1699
+ (return a cache layer or decline), `telemetry` (observe-only export:
1700
+ OTel, the GitHub job summary, a custom sink), `setup` and `teardown`
1701
+ around the run, and CLI `commands` (`{ verb: { description, run } }`,
1702
+ consulted for a verb core does not know). First-party plugins:
1703
+ `@vzn/vx-otel`, `@vzn/vx-ci`, `@vzn/vx-reapi`, `@vzn/vx-lockfile`,
1704
+ `@vzn/vx-schedule-history`, `@vzn/vx-mcp`, `@vzn/vx-migrate`. A
1705
+ plugin that declines every capability (`otel()` with no OTLP endpoint
1706
+ configured) costs nothing — a run with no active plugin is
1707
+ byte-identical to one with none declared. Plugins observe, route and
1708
+ execute; they never change what a task is.
1709
+
1710
+ The loader validates the shape (positive integer for `concurrency`,
1711
+ string for `cacheDir`, plugin objects with a name and at least one
1712
+ capability) and throws a `UserError` on
1713
+ malformed input.
1714
+
1715
+ There are no workspace-level `globalInputs` / task-default fields:
1716
+ root-anchored file inputs are declared per task via
1717
+ `cache.inputs.workspaceFiles`, root-anchored probes via
1718
+ `cache.inputs.workspaceRuntime`, and shared task shapes compose
1719
+ through plain TypeScript (import a preset and spread it) — the
1720
+ language replaces Nx-style `namedInputs` / `targetDefaults` schema
1721
+ machinery by design.
1722
+
1723
+ ## Helpers
1724
+
1725
+ ```ts
1726
+ import { defineProject, defineWorkspace } from '@vzn/vx/config'
1727
+
1728
+ // Identity functions; their purpose is type inference.
1729
+ defineProject<const T extends ProjectConfig>(config: T): T
1730
+ defineWorkspace<T extends WorkspaceConfig>(config: T): T
1731
+ ```
1732
+
1733
+ Use them so TypeScript narrows literal types in your config —
1734
+ autocomplete for task names in `dependsOn` against your declared
1735
+ tasks, strict validation against the schema, errors at edit time
1736
+ rather than at `vx run` time.
1737
+
1738
+ They cost one runtime import of `@vzn/vx/config` per config file:
1739
+ `src/config.ts`, which imports nothing, so it is cheap, but it must
1740
+ resolve from the config's directory. The type-only form gives the same
1741
+ editor checking with no runtime import, and is what `vx init` /
1742
+ `@vzn/vx-migrate` write:
1743
+
1744
+ ```ts
1745
+ import type { ProjectConfig, WorkspaceConfig } from '@vzn/vx/config'
1746
+ export default { tasks: { … } } satisfies ProjectConfig
1747
+ export default { plugins: [] } satisfies WorkspaceConfig
1748
+ ```
1749
+
1750
+ You _can_ skip the helpers and write
1751
+ `export default { tasks: { … } }`, but the IDE experience is
1752
+ strictly worse.
1753
+
1754
+ ## Full example
1755
+
1756
+ ```ts
1757
+ import { defineProject } from '@vzn/vx/config'
1758
+
1759
+ export default defineProject({
1760
+ tasks: {
1761
+ // Real cached task with file inputs, env tracking, and outputs.
1762
+ build: {
1763
+ description: 'compile TypeScript to dist/',
1764
+ // NODE_ENV is a cache input below, so it must reach the child
1765
+ // too — the exec env is isolated, not inherited.
1766
+ exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
1767
+ dependsOn: ['^build'],
1768
+ cache: {
1769
+ inputs: {
1770
+ files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
1771
+ env: ['NODE_ENV'],
1772
+ },
1773
+ outputs: { files: ['dist/**'] },
1774
+ },
1775
+ },
1776
+
1777
+ // Cached, depends on upstream build; CI is in passThrough only
1778
+ // (CI=1 shouldn't bust the cache — it's true on every CI run).
1779
+ test: {
1780
+ description: 'run unit tests',
1781
+ exec: { command: 'bun test', env: { passThrough: ['CI'] } },
1782
+ dependsOn: ['build'],
1783
+ cache: {
1784
+ inputs: { files: ['src/**', 'tests/**'] },
1785
+ outputs: { files: [] },
1786
+ },
1787
+ },
1788
+
1789
+ // Cross-project edges. The pkg.tgz from one project's build is
1790
+ // cached, and depends on builds in every workspace dep.
1791
+ package: {
1792
+ description: 'pack the npm tarball',
1793
+ exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' },
1794
+ dependsOn: ['build', 'test', '^build'],
1795
+ cache: {
1796
+ inputs: {
1797
+ files: ['package.json'],
1798
+ tasks: ['build', '^build'], // hash these upstream too
1799
+ },
1800
+ outputs: { files: ['pkg/*.tgz'] },
1801
+ },
1802
+ },
1803
+
1804
+ // Persistent dev server. Downstream tasks unblock on the
1805
+ // "Local:" line, not on exit (which never comes).
1806
+ dev: {
1807
+ description: 'vite dev server',
1808
+ exec: {
1809
+ command: 'vite',
1810
+ timeout: 30_000,
1811
+ persistent: { readyWhen: 'Local:' },
1812
+ env: { passThrough: ['VITE_API_URL'] },
1813
+ },
1814
+ },
1815
+
1816
+ // E2E test gated on the dev server. Together: vx run e2e runs
1817
+ // dev → e2e; e2e exits → orchestrator SIGTERMs dev → run ends.
1818
+ e2e: {
1819
+ description: 'end-to-end tests against dev server',
1820
+ exec: { command: 'playwright test' },
1821
+ dependsOn: ['dev'],
1822
+ },
1823
+
1824
+ // Two cached checks with no outputs: the verdict is the exit code.
1825
+ // A name with a hyphen is quoted, as any object key is.
1826
+ lint: {
1827
+ description: 'oxlint',
1828
+ exec: { command: 'oxlint .' },
1829
+ cache: { inputs: { files: ['src/**', '.oxlintrc.json'] }, outputs: { files: [] } },
1830
+ },
1831
+ 'format-check': {
1832
+ description: 'oxfmt --check',
1833
+ exec: { command: 'oxfmt --check .' },
1834
+ cache: { inputs: { files: ['src/**', '.oxfmtrc.json'] }, outputs: { files: [] } },
1835
+ },
1836
+
1837
+ // Pure group task — `vx run ci` fans out, the group itself is
1838
+ // silent in the run output. `dependsOn` is typed against the task
1839
+ // names above: a name declared nowhere is a type error, not a
1840
+ // silent no-op.
1841
+ ci: {
1842
+ description: 'format-check + lint + test',
1843
+ dependsOn: ['format-check', 'lint', 'test'],
1844
+ },
1845
+ },
1846
+ })
1847
+ ```
1848
+
1849
+ The rest of the task fields, in one config:
1850
+
1851
+ ```ts
1852
+ import { defineProject } from '@vzn/vx/config'
1853
+
1854
+ export default defineProject({
1855
+ tasks: {
1856
+ // Prompts on the terminal, so it runs alone and is never cached.
1857
+ 'db:push': { exec: { command: 'drizzle-kit push', interactive: true } },
1858
+ // Talks to this machine's Docker daemon: never sent to a remote executor.
1859
+ 'docker:build': { exec: { command: 'docker build -t app .', remote: false } },
1860
+ e2e: {
1861
+ exec: {
1862
+ command: 'playwright test',
1863
+ env: {
1864
+ define: { BASE_URL: 'http://localhost:3000' },
1865
+ passThrough: ['E2E_TOKEN'],
1866
+ secret: ['E2E_TOKEN'],
1867
+ },
1868
+ sandbox: {
1869
+ allow: {
1870
+ read: ['.'],
1871
+ write: ['test-results/'],
1872
+ localBinding: [3000],
1873
+ unixSockets: true,
1874
+ pty: true,
1875
+ gitConfig: false,
1876
+ systemInfo: ['hw.memsize'],
1877
+ machLookup: ['com.apple.SecurityServer'],
1878
+ },
1879
+ weakerNetworkIsolation: false,
1880
+ weakerWhenNested: false,
1881
+ },
1882
+ },
1883
+ cache: {
1884
+ inputs: {
1885
+ files: ['e2e/**'],
1886
+ runtime: ['npx playwright --version'],
1887
+ workspaceRuntime: ['node -v'],
1888
+ },
1889
+ outputs: { files: ['test-results/**'] },
1890
+ },
1891
+ },
1892
+ },
1893
+ })
1894
+ ```
1895
+
1896
+ ## Common patterns
1897
+
1898
+ ### Sharing inputs across tasks
1899
+
1900
+ TypeScript composition is the mechanism (named-input schema machinery
1901
+ was deliberately rejected — the language already does this). Plain TS
1902
+ arrays:
1903
+
1904
+ ```ts
1905
+ import { defineProject } from '@vzn/vx/config'
1906
+
1907
+ const srcInputs = ['src/**', 'tsconfig.json']
1908
+
1909
+ export default defineProject({
1910
+ tasks: {
1911
+ build: {
1912
+ exec: { command: 'tsc' },
1913
+ cache: { inputs: { files: srcInputs }, outputs: { files: ['dist/**'] } },
1914
+ },
1915
+ test: {
1916
+ exec: { command: 'bun test' },
1917
+ cache: { inputs: { files: [...srcInputs, 'tests/**'] }, outputs: { files: [] } },
1918
+ },
1919
+ },
1920
+ })
1921
+ ```
1922
+
1923
+ ### Presets
1924
+
1925
+ A preset is a TypeScript function that returns a `TaskConfig`:
1926
+
1927
+ ```ts
1928
+ // presets/ts-build.ts
1929
+ import type { TaskConfig } from '@vzn/vx/config'
1930
+
1931
+ export function tsBuild(opts?: { tsconfig?: string }): TaskConfig {
1932
+ return {
1933
+ description: 'compile TypeScript',
1934
+ exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() },
1935
+ cache: {
1936
+ inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
1937
+ outputs: { files: ['dist/**'] },
1938
+ },
1939
+ }
1940
+ }
1941
+ ```
1942
+
1943
+ ```ts
1944
+ // packages/app/vx.config.ts
1945
+ import { defineProject } from '@vzn/vx/config'
1946
+ import { tsBuild } from '../../presets/ts-build.ts'
1947
+
1948
+ export default defineProject({
1949
+ tasks: {
1950
+ build: tsBuild(),
1951
+ test: {
1952
+ exec: { command: 'bun test' },
1953
+ dependsOn: ['build'],
1954
+ cache: { inputs: { files: ['src/**'] }, outputs: { files: [] } },
1955
+ },
1956
+ },
1957
+ })
1958
+ ```
1959
+
1960
+ When the preset is consumed at config-load time, it should resolve
1961
+ to `.ts` source — see
1962
+ [`architecture.md` § Config-time imports](./architecture.md#config-time-imports--the-bootstrap-problem)
1963
+ for the bootstrap-cycle tradeoff.
1964
+
1965
+ ### When to use `cache.inputs.tasks: []`
1966
+
1967
+ When a `dependsOn` exists for ordering only, and the upstream's
1968
+ output doesn't influence this task's output. Common case: an
1969
+ integration test that depends on a dev server starting up but whose
1970
+ own inputs are just the test source.
1971
+
1972
+ ```ts
1973
+ e2e: {
1974
+ dependsOn: ['dev'],
1975
+ exec: { command: 'playwright test' },
1976
+ cache: {
1977
+ inputs: {
1978
+ files: ['e2e/**'],
1979
+ tasks: [], // dev's hash does not affect e2e's cache identity
1980
+ },
1981
+ outputs: { files: ['playwright-report/**'] },
1982
+ },
1983
+ }
1984
+ ```
1985
+
1986
+ ### When to use `--no-cache` instead of removing `cache`
1987
+
1988
+ `--no-cache` is a runtime flag, not a config change. Reach for it
1989
+ when:
1990
+
1991
+ - You suspect cache corruption and want a clean run.
1992
+ - You're benchmarking and need fresh timings.
1993
+ - You're forwarding one-off args and don't want to pollute the cache
1994
+ with a one-shot key (though note: forwarded args are already in
1995
+ the key, so they form their own entry anyway).
1996
+
1997
+ It applies to the whole run; per-task opt-out belongs in config (omit
1998
+ the `cache` block).
1999
+
2000
+ ## Schema validation errors
2001
+
2002
+ The loader (`src/workspace/project-loader.ts`) validates at load time
2003
+ and surfaces `UserError` (clean output, no stack). Every field's
2004
+ accepted values and exact refusal text are recorded in
2005
+ `tests/contract/config-schema.json`, which a test regenerates from the
2006
+ validator and compares, so a change to either is deliberate
2007
+ (`design/versioning-1.0.md` § How the contract is held).
2008
+
2009
+ When the field a refusal names is in the file, the CLI prints the file
2010
+ at its line and column, relative to the current directory (a terminal
2011
+ opens it on click), and the lines up to it with a caret under the field
2012
+ (`util/config-frame.ts`):
2013
+
2014
+ ```
2015
+ vx: packages/b/vx.config.ts:4:15: tasks.build.exec has unknown field "comand" (allowed: …) — did you mean command?
2016
+
2017
+ 2 | tasks: {
2018
+ 3 | build: {
2019
+ > 4 | exec: { comand: 'mkdir -p dist' },
2020
+ | ^
2021
+ ```
2022
+
2023
+ A field the file does not hold (a required one missing, or one a plugin
2024
+ set) prints the one line as before. The table below lists the messages
2025
+ a user meets most:
2026
+
2027
+ | Symptom | Cause |
2028
+ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
2029
+ | `did not export a default object` | Forgot `export default`, or exported a non-object; a function (Vite's `defineConfig(() => …)` shape) adds that it is one and to export what it returns (D-110). |
2030
+ | `tasks must be an object keyed by task name` | `tasks` is not an object — an ARRAY included. |
2031
+ | `<path> is <what> — a config must be JSON data, because the cache key folds its JSON` | A value JSON cannot carry, anywhere in the config: a function, a symbol, a bigint, `NaN` / `±Infinity`, `undefined` in an array, a cycle, a getter or an object that is not plain (`Date`, `Map`, `RegExp`, a class instance). |
2032
+ | `<level> has unknown field "<key>"` | Typo'd / unsupported key (see below). |
2033
+ | `<level> has field "<key>", which vx <version> removed — use <replacement>` | A field an earlier release accepted (`exec.resources`, removed in 0.0.19). The message names what replaced it (`design/versioning-1.0.md` § Deprecation). |
2034
+ | `<level> must be an object (fields: <fields>), not an array` | An array where an object goes — `outputs: ['dist/**']` (Turbo's spelling) is `outputs: { files: ['dist/**'] }`, and the message says so. |
2035
+ | `cannot find '<name>' — no node_modules above the config provides it; install the workspace's dependencies first` | A bare import nothing installed serves — a fresh clone before its install, or a typo. Refused before the config is evaluated, so Bun never auto-installs it from the registry (it would, when no `node_modules` exists above). |
2036
+ | `cannot find '<name>' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set <remedy>` | The same, with a `.pnp.cjs` at or above the config: the install is there and Bun cannot read it, so the remedy is `nodeLinker: node-modules` in `.yarnrc.yml` and `yarn install` (D-109). |
2037
+ | `tasks.<name> must be an object` | The task value is null / a string / etc.; a string (package.json's `name: 'command'`) adds the `{ exec: { command } }` it goes in. |
2038
+ | `exec must be an object with a command string` | `exec` is malformed. |
2039
+ | `exec.command must be a non-empty string` | Forgot `command`, or an empty or whitespace-only string. |
2040
+ | `exec.command holds a NUL, which no command line can carry` | A `\0` in the command (a template slip); the spawn refused it as exit 127, "not on this task's PATH", with the NUL printed as a space. |
2041
+ | `exec.persistent must be an object (or omitted)` | Wrong shape. |
2042
+ | `exec.persistent.readyWhen must be a string regex` | Non-string `readyWhen`. |
2043
+ | `exec.persistent.readyWhen is not a valid regex (<error>)` | A `readyWhen` the runner could not compile (`(`); it failed the task as an internal error at run time. |
2044
+ | `cache is not allowed on a persistent task` | persistent + cache combined. |
2045
+ | `exec.interactive must be a boolean (or omitted)` | Wrong shape. |
2046
+ | `cache is not allowed on an interactive task` | interactive + cache combined: what it does depends on what is typed. |
2047
+ | `sandbox is not allowed on an interactive task` | interactive + sandbox combined. |
2048
+ | `persistent.readyWhen is not allowed on an interactive task` | vx reads none of an interactive task's output. |
2049
+ | `a task with no exec must declare dependsOn` | Group task with no edges. |
2050
+ | `a task with no exec writes nothing` | Group task with `cache.outputs` that names a file. |
2051
+ | `dependsOn must be an array of strings` | Wrong shape. |
2052
+ | `cache.inputs is required when cache is set` | Forgot `inputs`. |
2053
+ | `cache.inputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2054
+ | `cache.inputs.files must be an array` | Wrong shape. |
2055
+ | `cache.inputs.runtime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2056
+ | `cache.inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2057
+ | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2058
+ | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2059
+ | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2060
+ | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2061
+ | `cache.outputs.files must be an array` | Wrong shape. |
2062
+ | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
2063
+ | `cache.inputs.files: "<file>" is taken back by "!<glob>"` | A literal input a `!` entry covers: the negation subtracts wherever it sits, so the file never entered the key. |
2064
+ | `cache.outputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs: a `!` entry only takes back what a positive glob selected (A-44). |
2065
+ | `cache.outputs.files: "<glob>" covers the project's own <file>` | An output glob that matches the project's `package.json` or its own `vx.config.*` (`**`, `*.json`): the clean before a run would delete them. A `!` entry that takes the file back (`!package.json`) lets it load. |
2066
+ | `cache.outputs.files: "<glob>" is under workspace-outputs/` | A project output named into the artifact's namespace for `outputs.workspaceFiles`: every hit read it back as a workspace output. |
2067
+ | `cache.inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
2068
+ | `exec.timeout: <n> ms exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
2069
+ | `description must be a string` | Non-string description. |
2070
+ | `tag "<tag>" <why> — --filter tag:<name> could not name it` | A tag with surrounding whitespace or a `*`, or one ending in `...` or `[<ref>]`, which a filter reads as a pattern, a dependency walk or a git range. |
2071
+
2072
+ **Unknown fields are rejected**, not ignored, at every object level —
2073
+ the project's top level (`tasks`), the task itself, `exec`, `exec.env`,
2074
+ `exec.persistent`, `exec.sandbox` and its `allow` /
2075
+ `deny` / `ignore` blocks, `cache`, `cache.inputs`, and `cache.outputs`
2076
+ (`tests/schema-unknown-keys.test.ts` walks every one) — and at the top
2077
+ of `vx.workspace.ts`,
2078
+ where `plugin:` (singular) would otherwise declare no plugins and run
2079
+ the workspace bare. A silently-dropped
2080
+ `workspaceFile` (singular) or `timeoutMs` would make the task hash as
2081
+ though the field had never been written, so vx would replay an artifact
2082
+ built from different inputs; a silently-dropped `env: { set: … }` would
2083
+ run the task without the variables it was written to have. The error
2084
+ names the offending key, lists what that level accepts, and adds the
2085
+ nearest accepted spelling when one is within two edits:
2086
+ `tasks.build.exec.env has unknown field "passthrough" (allowed: define,
2087
+ passThrough, secret) — did you mean passThrough?`. A field another runner spells
2088
+ on the task names vx's home for it instead (D-37): Turbo's `outputs`,
2089
+ `inputs`, `env`, `passThroughEnv`, `persistent`, `outputLogs`, `interactive`
2090
+ (`exec.interactive`) and `with`, Nx's target `executor`, `options`, `continuous` (D-49), `cwd`,
2091
+ `parallelism` and `configurations` (D-89), and a `command`
2092
+ (`cmd`, `script`) on the task or `cmd` on `exec`, and on `exec` Nx
2093
+ run-commands' `cwd`, `args`, `commands`, `parallel` and `shell`
2094
+ (D-100), and in a `cache` block wireit's `files` / `output`,
2095
+ `env`, `dependencies`, `enabled`, and `globs` / `include` / `patterns` /
2096
+ `exclude` / `ignore` under `inputs` or `outputs` (D-118). So `outputs` on a task
2097
+ ends `— vx spells it cache.outputs.files` in code quotes. A `cache` that
2098
+ is no object (Turbo's `cache: false`) and a `persistent` that is none
2099
+ (`true`) name the shape to write.
2100
+
2101
+ Workspace-discovery errors (`src/workspace/workspace.ts`):
2102
+
2103
+ | Symptom | Cause |
2104
+ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2105
+ | `failed to parse <file>: <why>` | A `package.json` / `pnpm-workspace.yaml` is not valid. |
2106
+ | `<file>: packages must be an array of glob strings` | `pnpm-workspace.yaml` `packages:` is a bare string, etc. |
2107
+ | `<file>: must be a JSON object` | A `package.json` (the root's or a member's) is `null`, a list or a scalar; it crashed with a TypeError until item 988. |
2108
+ | `<file>: "name" must be a string with no surrounding whitespace` | A `package.json` `name` is a number, an object, or has surrounding whitespace (npm refuses one too); `{"name":123}` planned `123#build` until item 988. |
2109
+ | `<file>: "name" cannot hold "#" — vx addresses a task as <name>#<task>` | A `package.json` `name` holds `#` (npm refuses one too): `{"name":"a#b"}` planned `a#b#build` under `--all`, but `vx run a#b#build` and a `dependsOn` split at the first `#` and found nothing. |
2110
+ | `<file>: must be a mapping (packages: and pnpm's settings)` | `pnpm-workspace.yaml` is a list or a scalar. A mapping with no `packages:`, or an empty one (pnpm 10 settings or catalogs in a single-package repo) is not an error: the root's `package.json` decides, as without the file (item 984). |
2111
+ | `<file>: workspaces must be an array of glob strings` | `package.json` `workspaces` holds a non-string entry. |
2112
+ | `workspace member <dir> (<abs>) is outside the workspace root <root>: vx keeps every project under the root` | A member glob (`../ext/*`, an absolute path) reached a package outside the root. npm and pnpm take one; `--affected` asks git from the root and missed edits there. Move the root up to a directory that holds every member. |
2113
+ | `<file>: workspaces.packages must be an array of glob strings` | The yarn-legacy `workspaces: { packages: [...] }` form holds a non-string entry. |
2114
+ | `<file>: <field> entry "<glob>" is an extglob, which vx's glob engine does not read` | A member glob holds `!(…)`, `@(…)`, `+(…)`, `*(…)` or `?(…)`. `Bun.Glob` has no extglob (its scan widened `packages/!(x)` to include x, turborepo#3766); a whole-segment `!(a\|b)` gets its exact rewrite, `["packages/*", "!packages/a", "!packages/b"]`. |
2115
+
2116
+ Workspace-config errors:
2117
+
2118
+ | Symptom | Cause |
2119
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2120
+ | `concurrency must be a positive integer` | `concurrency` is negative, zero, NaN, ... |
2121
+ | `timeout must be a positive integer (milliseconds)` | Workspace `timeout` is ≤ 0, NaN, or not an int. |
2122
+ | `cacheDir must be a string` | Wrong shape. |
2123
+ | `cacheDir is only whitespace — name a directory` | A `cacheDir` of spaces: it made a directory named so at the root, hidden by the cache's own `.gitignore`. |
2124
+ | `cacheDir holds a NUL, which no path can carry` | No path can hold a NUL; the cache directory's creation failed and blamed the workspace's permissions. |
2125
+ | `cacheRetention must be { olderThan?: '30d', maxSize?: '10G' }` | Not an object. |
2126
+ | `cacheRetention names neither olderThan nor maxSize` | An empty policy would evict nothing and read as one. |
2127
+ | `cacheRetention.olderThan must be a duration like '30d', '12h', '90m' or '45s'` | The `vx cache prune --older-than` spelling, or not a string. |
2128
+ | `cacheRetention.maxSize must be a size like '10G', '500MB' or '64KB'` | The `vx cache prune --max-size` spelling (no fractions), or not a string. |
2129
+ | `cacheRetention.olderThan of 0 evicts every entry after every run` | Every run would evict what it just saved; `vx cache prune --older-than 0` is refused too. |
2130
+ | `cacheRetention.maxSize of 0 evicts every entry after every run` | The same, for the size bound. |
2131
+ | `cacheRetention.maxSize '<n>' reads as <n> bytes — give a unit (e.g. '<n>M', '<n>G')` | A bare number is bytes; a cache capped at `10` bytes is a typo for `10G`. `10B` still loads. |
2132
+ | `affectedBase must be a git ref like 'origin/main'` | Not a string, empty, holding whitespace or a NUL, or opening with `-` (git would read an option). |
2133
+ | `cacheScope must be 'trusted', 'read-only', or a scope name of <rule>` | Not a string, or a scope name holding a character outside letters, digits and `. _ - / @`, or longer than 128. |
2134
+ | `rules must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }` | Not an object. |
2135
+ | `rules.<name> must be true or false` | A rule set to something other than a boolean (`'off'`, `0`). |
2136
+ | `plugins must be an array of plugin objects` | Wrong shape. |
2137
+ | `plugins[<i>] must be an object` | A non-object entry in `plugins`; a string (Nx's `'@nx/vite/plugin'`) adds that a plugin is what its package's function returns, not a module name (D-49). |
2138
+ | `plugins[<i>] must come from definePlugin(import.meta, { … })` | A plain object where a plugin was expected: a plugin's name is its package name, and only `definePlugin` sets it. |
2139
+ | `plugins[<i>] is a Promise — await the factory that returns the plugin` | An async plugin factory called without `await` (`plugins: [later()]`); the refusal said the plugin did not come from `definePlugin` (D-159). |
2140
+ | `plugins[<i>].name overrides the package name` | A `name` set over `definePlugin`'s result; drop the field. |
2141
+ | `plugins[<i>].<capability> of plugin '<name>' must be a function` | A capability key holding something that is not callable. |
2142
+ | `plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/schedule/admit/executor/cache/telemetry/setup/commands/teardown` | A plugin object with no capability. |
2143
+ | `plugin '<name>' declares '<key>', which is no plugin hook` | A key on a plugin that names no hook (`excutor`, `setUp`): it was never called. The nearest hook is hinted. |
2144
+ | `<file> has unknown field "<key>"` | Typo'd / unsupported top-level key (`plugin`); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (`pipeline`, `remoteCache`, `parallel`, `cacheDirectory`, `defaultBase`, `tasksRunnerOptions`, `globalPassThroughEnv`; D-38, D-49; pnpm-workspace.yaml's, Lerna's and package.json's `packages`, `workspaces`, `catalog`, `npmClient`, and `ignore`, `cache`, `remote`, `env`, D-98; in a project's vx.config, `targets`, `extends`, `implicitDependencies` and `name`, D-56; `scripts`, `pipeline`, `namedInputs`, `root`, `sourceRoot`, `projectType`, and a task's `dependsOn`, `cache`, `exec`, `inputs`, `outputs`, `env` one level too high, D-99). |
2145
+ | `plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed` | A plugin verb the dispatcher matches first; it could never run. |
2146
+ | `plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-'` | A verb like `--version`, `''` or `a b`: core reads a flag first, an empty word is no verb, and `vx completions` writes verbs unquoted into a shell script. |
2147
+ | `plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner` | Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs. |
2148
+ | `plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function }` | A fingerprint claim without its file list or its `affected` answer. |
2149
+ | `plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root` | A claim names a path, or no name: a claim is a bare file at the root (`vx watch`'s root arm is not recursive). |
2150
+ | `plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant` | Two plugins keying the same lockfile; the key would fold both and `--affected` could ask only one. |
2151
+ | `plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once` | One plugin package declared twice (`plugins: [bun(), bun()]`); the message named it as two plugins (D-158). |