@vzn/vx 0.0.632 → 0.0.633
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/skills/vx/SKILL.md +6 -2
- package/src/cache/cache.ts +500 -121
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +35 -6
- package/src/cli/lock.ts +43 -15
- package/src/cli/upgrade.ts +18 -6
- package/src/graph/task-graph.ts +3 -3
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
package/docs/schema.md
ADDED
|
@@ -0,0 +1,2137 @@
|
|
|
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
|
+
plugins: [otel()],
|
|
1538
|
+
})
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
```ts
|
|
1542
|
+
interface WorkspaceConfig {
|
|
1543
|
+
/** Maximum concurrent tasks. Defaults to the cores this process may use (the CPU count, capped by a cgroup quota). */
|
|
1544
|
+
concurrency?: number
|
|
1545
|
+
/** Cache directory, relative to workspace root. Named, it holds the whole cache; unset, entries live in the user's shared store. */
|
|
1546
|
+
cacheDir?: string
|
|
1547
|
+
/** Default per-task timeout (ms) for tasks without their own exec.timeout. */
|
|
1548
|
+
timeout?: number
|
|
1549
|
+
/** Evict from the local cache at the end of each run (the `vx cache prune` policy). */
|
|
1550
|
+
cacheRetention?: { olderThan?: string; maxSize?: string }
|
|
1551
|
+
/** The git ref a bare `--affected` compares with. */
|
|
1552
|
+
affectedBase?: string
|
|
1553
|
+
/** Where remote writes land: 'trusted' (default), 'read-only', or an untrusted scope name. */
|
|
1554
|
+
cacheScope?: string
|
|
1555
|
+
/** Graph rules checked before anything runs; each on unless set to false. */
|
|
1556
|
+
rules?: WorkspaceRules
|
|
1557
|
+
/** Run-level plugins (cache / executor / telemetry capabilities). */
|
|
1558
|
+
plugins?: readonly Plugin[]
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
interface WorkspaceRules {
|
|
1562
|
+
/** Refuse overlapping outputs even across a dependsOn edge. Default true. */
|
|
1563
|
+
exclusiveOutputs?: boolean
|
|
1564
|
+
/** Refuse a task whose inputs can match another task's outputs. Default true. */
|
|
1565
|
+
upfrontKeys?: boolean
|
|
1566
|
+
}
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
- **`concurrency`** — used as the default cap on parallel tasks; the
|
|
1570
|
+
CLI `--concurrency <n>` still wins when passed.
|
|
1571
|
+
- **`timeout`** — the lowest-precedence default per-task timeout (ms),
|
|
1572
|
+
applied to any task that declares no `exec.timeout`. Precedence,
|
|
1573
|
+
highest first: per-task `exec.timeout` → `--timeout` /
|
|
1574
|
+
`RunOptions.timeout` → `VX_TASK_TIMEOUT` env → this. A runaway task's
|
|
1575
|
+
process group is SIGTERMed and the task reported `failed`. Purely a safety net — never folded
|
|
1576
|
+
into a cache key (a timed-out task fails and is never cached).
|
|
1577
|
+
- **`cacheDir`** — unset, the workspace keeps its index and history in
|
|
1578
|
+
`.vx/cache` and its entries and artifacts in its repository's shared
|
|
1579
|
+
store (`~/.vx/<id>/cache`), where every other checkout of the
|
|
1580
|
+
repository hits them (`docs/caching.md`). Named here, by
|
|
1581
|
+
`VX_CACHE_DIR`, or by `--cache-dir`, the directory holds the whole
|
|
1582
|
+
cache, shared with no other workspace.
|
|
1583
|
+
Relative paths are resolved against the workspace
|
|
1584
|
+
root, `~/` against the home directory; absolute paths are used
|
|
1585
|
+
as-is. The home directory itself (`~`) is refused. `vx run`, `vx cache prune`,
|
|
1586
|
+
and any other reader use the same resolution
|
|
1587
|
+
(`src/workspace/workspace.ts:resolveCacheDir`). The cache is a
|
|
1588
|
+
directory of its own: a first index in one that holds a
|
|
1589
|
+
`package.json` or `pnpm-workspace.yaml` (`''` and `'.'` name the
|
|
1590
|
+
root), whose subdirectory does (`'packages'`), or that holds the
|
|
1591
|
+
workspace (`'..'`, `'/'`) is refused before anything is written, since its `*`
|
|
1592
|
+
`.gitignore` would hide the sources from git and the cache keys.
|
|
1593
|
+
- **`cacheRetention`** — the `vx cache prune` policy, applied at the
|
|
1594
|
+
end of every run: entries unused for
|
|
1595
|
+
`olderThan` (`30d`, `12h`, `90m`, `45s`) go first, then the
|
|
1596
|
+
least-recently-used until the cache is under `maxSize` (`10G`,
|
|
1597
|
+
`500MB`, `64KB`; a bare number is refused as a typo, `10B` is not).
|
|
1598
|
+
Either or both, and neither may be zero: each would evict what every
|
|
1599
|
+
run just saved (item 969). It runs after the run's saves
|
|
1600
|
+
and uploads have landed, never on a run a signal or an abort stopped
|
|
1601
|
+
(one stopped while it waited on the workspace lock never held it), only when something is due (a run with
|
|
1602
|
+
nothing to evict pays one scan of the index), and says nothing:
|
|
1603
|
+
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
|
|
1604
|
+
orphan sweep (artifacts no index row counts, judged by file time —
|
|
1605
|
+
see `vx cache prune`) also runs on its own clock, at most once an
|
|
1606
|
+
hour, so their bytes go even when nothing the index holds is due;
|
|
1607
|
+
listing the directory every run would cost 0.5 ms per 1,000
|
|
1608
|
+
entries. Housekeeping,
|
|
1609
|
+
not the run's work: a failure is silent, never a failed run. Not
|
|
1610
|
+
folded into any cache key. Omitted → the cache grows until
|
|
1611
|
+
`vx cache prune`.
|
|
1612
|
+
- **`affectedBase`** — the git ref a bare `--affected` compares with
|
|
1613
|
+
(`origin/develop`); `--affected=<base>` still wins. Omitted →
|
|
1614
|
+
`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:
|
|
1615
|
+
`nx()` from `NX_BASE` or nx.json's `defaultBase`, `turbo()` from
|
|
1616
|
+
`TURBO_SCM_BASE`, else on GitHub Actions from `GITHUB_BASE_REF` or the
|
|
1617
|
+
push event's `before`, as Turbo does. Not folded into any cache key.
|
|
1618
|
+
- **`cacheScope`** — where this run's remote cache writes land.
|
|
1619
|
+
`'trusted'` reads and writes the task keys: the default branch's CI.
|
|
1620
|
+
`'read-only'` reads them and writes nothing: a laptop. Omitted, `vx
|
|
1621
|
+
run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
1622
|
+
`'trusted'` on CI or when `--cache` names the remote; `run()` takes
|
|
1623
|
+
`RunOptions.defaultCacheScope`, else `'trusted'`.
|
|
1624
|
+
Any other name (`'pr-123'`; letters, digits, `. _ - / @`, at most 128) is an untrusted scope: a remote read tries the scope's key, then
|
|
1625
|
+
the trusted one, and a write goes to the scope's only, so a pull request
|
|
1626
|
+
never writes what the default branch reads, and two scopes never see
|
|
1627
|
+
each other. A scope's key is derived from the task key, so every
|
|
1628
|
+
remote wire stores it unchanged. A clamp on `--cache`, never a
|
|
1629
|
+
widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
|
|
1630
|
+
the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
|
|
1631
|
+
boundary: a run holding a write credential can write any key, so only
|
|
1632
|
+
a cache server that scopes writes by token can refuse one
|
|
1633
|
+
(`docs/security.md` § Cache poisoning). Not folded into any cache key.
|
|
1634
|
+
- **`rules`** — checks on the task graph, each on unless set to
|
|
1635
|
+
`false`. Turning one off allows a shape vx runs correctly but more
|
|
1636
|
+
slowly; never folded into a cache key. A value that is not a boolean,
|
|
1637
|
+
or a rule vx does not know, is refused.
|
|
1638
|
+
- **`exclusiveOutputs`** (X-53) — two tasks whose declared outputs
|
|
1639
|
+
overlap are refused even when a `dependsOn` edge orders them:
|
|
1640
|
+
`<a> and <b> both declare the output "<glob>" … Give each task its
|
|
1641
|
+
own output path, or set rules: { exclusiveOutputs: false } in
|
|
1642
|
+
vx.workspace.ts to let a dependant add to its upstream's outputs.`
|
|
1643
|
+
Off, the dependant adds to its upstream's tree (item 588;
|
|
1644
|
+
`caching.md` § Additive outputs). Two overlapping tasks with no
|
|
1645
|
+
edge are refused either way.
|
|
1646
|
+
- **`upfrontKeys`** (X-54) — a cached task whose `inputs.files` can
|
|
1647
|
+
match a same-project task's `outputs.files`, or whose
|
|
1648
|
+
`inputs.workspaceFiles` can match any task's `outputs.workspaceFiles`
|
|
1649
|
+
or another project's `outputs.files` read from the root, or whose
|
|
1650
|
+
`inputs.files` can match another task's `outputs.workspaceFiles`
|
|
1651
|
+
landing in its project (X-135), is refused,
|
|
1652
|
+
edge or no edge: `<reader> reads "<glob>" in cache.inputs.<field>,
|
|
1653
|
+
which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
|
|
1654
|
+
to <reader>'s cache.inputs.<field>, or set rules: { upfrontKeys:
|
|
1655
|
+
false } in vx.workspace.ts to let it wait for its producer.` Such a key
|
|
1656
|
+
reads what the producer writes this run, so vx cannot know it before
|
|
1657
|
+
the producer ran: it is not probed, prefetched or restored ahead of
|
|
1658
|
+
the schedule (`caching.md` § Local restore tier). The upstream's key
|
|
1659
|
+
already reaches the reader through `dependsOn`. A `!` entry that
|
|
1660
|
+
takes the whole output back clears it, and an output the reader's
|
|
1661
|
+
`!` entries take back no longer holds its key back either. A task's
|
|
1662
|
+
own outputs, a task with no `cache`, and a group are exempt. Off,
|
|
1663
|
+
the key waits for its producer, as before.
|
|
1664
|
+
- **`plugins`** — the run-level extension points. Optional: core
|
|
1665
|
+
applies no plugin by default, and the local executor and the local
|
|
1666
|
+
cache are its floor — the tail of every executor list and cache chain
|
|
1667
|
+
— so a workspace that declares none runs and caches here.
|
|
1668
|
+
Declaration order is precedence: every `executor` is consulted in
|
|
1669
|
+
order per task (first to accept runs it; what all decline runs
|
|
1670
|
+
locally); every `cache` layer is chained (lookup walks, save reaches
|
|
1671
|
+
all, the local store last). Each entry is a `VxPlugin` contributing
|
|
1672
|
+
any subset of the fourteen hooks, in pipeline order: `config` (edit
|
|
1673
|
+
the workspace config before anything derives from it), `discover`
|
|
1674
|
+
(name directories to make projects beyond the members), `project`
|
|
1675
|
+
(edit a loaded project's tasks — inject, remove, rewrite;
|
|
1676
|
+
re-validated by core and hashed into the key like a hand edit),
|
|
1677
|
+
`graph` (edit the task graph's edges), `key` (per-task
|
|
1678
|
+
`{ name: value }` material folded into the cache key), `fingerprint`
|
|
1679
|
+
(claim a lockfile and key it per project, or a root file the plugin's
|
|
1680
|
+
stages read, such as `turbo.json`, so `--affected` asks the plugin
|
|
1681
|
+
about it and `vx watch` re-runs on it), `schedule` (task id →
|
|
1682
|
+
priority, merged over the scheduler's baseline), `admit` (may this
|
|
1683
|
+
ready task start now beside what runs here), `executor` (return a
|
|
1684
|
+
`TaskExecutor` — where one task's command runs — or decline), `cache`
|
|
1685
|
+
(return a cache layer or decline), `telemetry` (observe-only export:
|
|
1686
|
+
OTel, the GitHub job summary, a custom sink), `setup` and `teardown`
|
|
1687
|
+
around the run, and CLI `commands` (`{ verb: { description, run } }`,
|
|
1688
|
+
consulted for a verb core does not know). First-party plugins:
|
|
1689
|
+
`@vzn/vx-otel`, `@vzn/vx-ci`, `@vzn/vx-reapi`, `@vzn/vx-lockfile`,
|
|
1690
|
+
`@vzn/vx-schedule-history`, `@vzn/vx-mcp`, `@vzn/vx-migrate`. A
|
|
1691
|
+
plugin that declines every capability (`otel()` with no OTLP endpoint
|
|
1692
|
+
configured) costs nothing — a run with no active plugin is
|
|
1693
|
+
byte-identical to one with none declared. Plugins observe, route and
|
|
1694
|
+
execute; they never change what a task is.
|
|
1695
|
+
|
|
1696
|
+
The loader validates the shape (positive integer for `concurrency`,
|
|
1697
|
+
string for `cacheDir`, plugin objects with a name and at least one
|
|
1698
|
+
capability) and throws a `UserError` on
|
|
1699
|
+
malformed input.
|
|
1700
|
+
|
|
1701
|
+
There are no workspace-level `globalInputs` / task-default fields:
|
|
1702
|
+
root-anchored file inputs are declared per task via
|
|
1703
|
+
`cache.inputs.workspaceFiles`, root-anchored probes via
|
|
1704
|
+
`cache.inputs.workspaceRuntime`, and shared task shapes compose
|
|
1705
|
+
through plain TypeScript (import a preset and spread it) — the
|
|
1706
|
+
language replaces Nx-style `namedInputs` / `targetDefaults` schema
|
|
1707
|
+
machinery by design.
|
|
1708
|
+
|
|
1709
|
+
## Helpers
|
|
1710
|
+
|
|
1711
|
+
```ts
|
|
1712
|
+
import { defineProject, defineWorkspace } from '@vzn/vx/config'
|
|
1713
|
+
|
|
1714
|
+
// Identity functions; their purpose is type inference.
|
|
1715
|
+
defineProject<const T extends ProjectConfig>(config: T): T
|
|
1716
|
+
defineWorkspace<T extends WorkspaceConfig>(config: T): T
|
|
1717
|
+
```
|
|
1718
|
+
|
|
1719
|
+
Use them so TypeScript narrows literal types in your config —
|
|
1720
|
+
autocomplete for task names in `dependsOn` against your declared
|
|
1721
|
+
tasks, strict validation against the schema, errors at edit time
|
|
1722
|
+
rather than at `vx run` time.
|
|
1723
|
+
|
|
1724
|
+
They cost one runtime import of `@vzn/vx/config` per config file:
|
|
1725
|
+
`src/config.ts`, which imports nothing, so it is cheap, but it must
|
|
1726
|
+
resolve from the config's directory. The type-only form gives the same
|
|
1727
|
+
editor checking with no runtime import, and is what `vx init` /
|
|
1728
|
+
`@vzn/vx-migrate` write:
|
|
1729
|
+
|
|
1730
|
+
```ts
|
|
1731
|
+
import type { ProjectConfig, WorkspaceConfig } from '@vzn/vx/config'
|
|
1732
|
+
export default { tasks: { … } } satisfies ProjectConfig
|
|
1733
|
+
export default { plugins: [] } satisfies WorkspaceConfig
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
You _can_ skip the helpers and write
|
|
1737
|
+
`export default { tasks: { … } }`, but the IDE experience is
|
|
1738
|
+
strictly worse.
|
|
1739
|
+
|
|
1740
|
+
## Full example
|
|
1741
|
+
|
|
1742
|
+
```ts
|
|
1743
|
+
import { defineProject } from '@vzn/vx/config'
|
|
1744
|
+
|
|
1745
|
+
export default defineProject({
|
|
1746
|
+
tasks: {
|
|
1747
|
+
// Real cached task with file inputs, env tracking, and outputs.
|
|
1748
|
+
build: {
|
|
1749
|
+
description: 'compile TypeScript to dist/',
|
|
1750
|
+
// NODE_ENV is a cache input below, so it must reach the child
|
|
1751
|
+
// too — the exec env is isolated, not inherited.
|
|
1752
|
+
exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
|
|
1753
|
+
dependsOn: ['^build'],
|
|
1754
|
+
cache: {
|
|
1755
|
+
inputs: {
|
|
1756
|
+
files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
|
|
1757
|
+
env: ['NODE_ENV'],
|
|
1758
|
+
},
|
|
1759
|
+
outputs: { files: ['dist/**'] },
|
|
1760
|
+
},
|
|
1761
|
+
},
|
|
1762
|
+
|
|
1763
|
+
// Cached, depends on upstream build; CI is in passThrough only
|
|
1764
|
+
// (CI=1 shouldn't bust the cache — it's true on every CI run).
|
|
1765
|
+
test: {
|
|
1766
|
+
description: 'run unit tests',
|
|
1767
|
+
exec: { command: 'bun test', env: { passThrough: ['CI'] } },
|
|
1768
|
+
dependsOn: ['build'],
|
|
1769
|
+
cache: {
|
|
1770
|
+
inputs: { files: ['src/**', 'tests/**'] },
|
|
1771
|
+
outputs: { files: [] },
|
|
1772
|
+
},
|
|
1773
|
+
},
|
|
1774
|
+
|
|
1775
|
+
// Cross-project edges. The pkg.tgz from one project's build is
|
|
1776
|
+
// cached, and depends on builds in every workspace dep.
|
|
1777
|
+
package: {
|
|
1778
|
+
description: 'pack the npm tarball',
|
|
1779
|
+
exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' },
|
|
1780
|
+
dependsOn: ['build', 'test', '^build'],
|
|
1781
|
+
cache: {
|
|
1782
|
+
inputs: {
|
|
1783
|
+
files: ['package.json'],
|
|
1784
|
+
tasks: ['build', '^build'], // hash these upstream too
|
|
1785
|
+
},
|
|
1786
|
+
outputs: { files: ['pkg/*.tgz'] },
|
|
1787
|
+
},
|
|
1788
|
+
},
|
|
1789
|
+
|
|
1790
|
+
// Persistent dev server. Downstream tasks unblock on the
|
|
1791
|
+
// "Local:" line, not on exit (which never comes).
|
|
1792
|
+
dev: {
|
|
1793
|
+
description: 'vite dev server',
|
|
1794
|
+
exec: {
|
|
1795
|
+
command: 'vite',
|
|
1796
|
+
timeout: 30_000,
|
|
1797
|
+
persistent: { readyWhen: 'Local:' },
|
|
1798
|
+
env: { passThrough: ['VITE_API_URL'] },
|
|
1799
|
+
},
|
|
1800
|
+
},
|
|
1801
|
+
|
|
1802
|
+
// E2E test gated on the dev server. Together: vx run e2e runs
|
|
1803
|
+
// dev → e2e; e2e exits → orchestrator SIGTERMs dev → run ends.
|
|
1804
|
+
e2e: {
|
|
1805
|
+
description: 'end-to-end tests against dev server',
|
|
1806
|
+
exec: { command: 'playwright test' },
|
|
1807
|
+
dependsOn: ['dev'],
|
|
1808
|
+
},
|
|
1809
|
+
|
|
1810
|
+
// Two cached checks with no outputs: the verdict is the exit code.
|
|
1811
|
+
// A name with a hyphen is quoted, as any object key is.
|
|
1812
|
+
lint: {
|
|
1813
|
+
description: 'oxlint',
|
|
1814
|
+
exec: { command: 'oxlint .' },
|
|
1815
|
+
cache: { inputs: { files: ['src/**', '.oxlintrc.json'] }, outputs: { files: [] } },
|
|
1816
|
+
},
|
|
1817
|
+
'format-check': {
|
|
1818
|
+
description: 'oxfmt --check',
|
|
1819
|
+
exec: { command: 'oxfmt --check .' },
|
|
1820
|
+
cache: { inputs: { files: ['src/**', '.oxfmtrc.json'] }, outputs: { files: [] } },
|
|
1821
|
+
},
|
|
1822
|
+
|
|
1823
|
+
// Pure group task — `vx run ci` fans out, the group itself is
|
|
1824
|
+
// silent in the run output. `dependsOn` is typed against the task
|
|
1825
|
+
// names above: a name declared nowhere is a type error, not a
|
|
1826
|
+
// silent no-op.
|
|
1827
|
+
ci: {
|
|
1828
|
+
description: 'format-check + lint + test',
|
|
1829
|
+
dependsOn: ['format-check', 'lint', 'test'],
|
|
1830
|
+
},
|
|
1831
|
+
},
|
|
1832
|
+
})
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
The rest of the task fields, in one config:
|
|
1836
|
+
|
|
1837
|
+
```ts
|
|
1838
|
+
import { defineProject } from '@vzn/vx/config'
|
|
1839
|
+
|
|
1840
|
+
export default defineProject({
|
|
1841
|
+
tasks: {
|
|
1842
|
+
// Prompts on the terminal, so it runs alone and is never cached.
|
|
1843
|
+
'db:push': { exec: { command: 'drizzle-kit push', interactive: true } },
|
|
1844
|
+
// Talks to this machine's Docker daemon: never sent to a remote executor.
|
|
1845
|
+
'docker:build': { exec: { command: 'docker build -t app .', remote: false } },
|
|
1846
|
+
e2e: {
|
|
1847
|
+
exec: {
|
|
1848
|
+
command: 'playwright test',
|
|
1849
|
+
env: {
|
|
1850
|
+
define: { BASE_URL: 'http://localhost:3000' },
|
|
1851
|
+
passThrough: ['E2E_TOKEN'],
|
|
1852
|
+
secret: ['E2E_TOKEN'],
|
|
1853
|
+
},
|
|
1854
|
+
sandbox: {
|
|
1855
|
+
allow: {
|
|
1856
|
+
read: ['.'],
|
|
1857
|
+
write: ['test-results/'],
|
|
1858
|
+
localBinding: [3000],
|
|
1859
|
+
unixSockets: true,
|
|
1860
|
+
pty: true,
|
|
1861
|
+
gitConfig: false,
|
|
1862
|
+
systemInfo: ['hw.memsize'],
|
|
1863
|
+
machLookup: ['com.apple.SecurityServer'],
|
|
1864
|
+
},
|
|
1865
|
+
weakerNetworkIsolation: false,
|
|
1866
|
+
weakerWhenNested: false,
|
|
1867
|
+
},
|
|
1868
|
+
},
|
|
1869
|
+
cache: {
|
|
1870
|
+
inputs: {
|
|
1871
|
+
files: ['e2e/**'],
|
|
1872
|
+
runtime: ['npx playwright --version'],
|
|
1873
|
+
workspaceRuntime: ['node -v'],
|
|
1874
|
+
},
|
|
1875
|
+
outputs: { files: ['test-results/**'] },
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1879
|
+
})
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
## Common patterns
|
|
1883
|
+
|
|
1884
|
+
### Sharing inputs across tasks
|
|
1885
|
+
|
|
1886
|
+
TypeScript composition is the mechanism (named-input schema machinery
|
|
1887
|
+
was deliberately rejected — the language already does this). Plain TS
|
|
1888
|
+
arrays:
|
|
1889
|
+
|
|
1890
|
+
```ts
|
|
1891
|
+
import { defineProject } from '@vzn/vx/config'
|
|
1892
|
+
|
|
1893
|
+
const srcInputs = ['src/**', 'tsconfig.json']
|
|
1894
|
+
|
|
1895
|
+
export default defineProject({
|
|
1896
|
+
tasks: {
|
|
1897
|
+
build: {
|
|
1898
|
+
exec: { command: 'tsc' },
|
|
1899
|
+
cache: { inputs: { files: srcInputs }, outputs: { files: ['dist/**'] } },
|
|
1900
|
+
},
|
|
1901
|
+
test: {
|
|
1902
|
+
exec: { command: 'bun test' },
|
|
1903
|
+
cache: { inputs: { files: [...srcInputs, 'tests/**'] }, outputs: { files: [] } },
|
|
1904
|
+
},
|
|
1905
|
+
},
|
|
1906
|
+
})
|
|
1907
|
+
```
|
|
1908
|
+
|
|
1909
|
+
### Presets
|
|
1910
|
+
|
|
1911
|
+
A preset is a TypeScript function that returns a `TaskConfig`:
|
|
1912
|
+
|
|
1913
|
+
```ts
|
|
1914
|
+
// presets/ts-build.ts
|
|
1915
|
+
import type { TaskConfig } from '@vzn/vx/config'
|
|
1916
|
+
|
|
1917
|
+
export function tsBuild(opts?: { tsconfig?: string }): TaskConfig {
|
|
1918
|
+
return {
|
|
1919
|
+
description: 'compile TypeScript',
|
|
1920
|
+
exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() },
|
|
1921
|
+
cache: {
|
|
1922
|
+
inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
|
|
1923
|
+
outputs: { files: ['dist/**'] },
|
|
1924
|
+
},
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
```ts
|
|
1930
|
+
// packages/app/vx.config.ts
|
|
1931
|
+
import { defineProject } from '@vzn/vx/config'
|
|
1932
|
+
import { tsBuild } from '../../presets/ts-build.ts'
|
|
1933
|
+
|
|
1934
|
+
export default defineProject({
|
|
1935
|
+
tasks: {
|
|
1936
|
+
build: tsBuild(),
|
|
1937
|
+
test: {
|
|
1938
|
+
exec: { command: 'bun test' },
|
|
1939
|
+
dependsOn: ['build'],
|
|
1940
|
+
cache: { inputs: { files: ['src/**'] }, outputs: { files: [] } },
|
|
1941
|
+
},
|
|
1942
|
+
},
|
|
1943
|
+
})
|
|
1944
|
+
```
|
|
1945
|
+
|
|
1946
|
+
When the preset is consumed at config-load time, it should resolve
|
|
1947
|
+
to `.ts` source — see
|
|
1948
|
+
[`architecture.md` § Config-time imports](./architecture.md#config-time-imports--the-bootstrap-problem)
|
|
1949
|
+
for the bootstrap-cycle tradeoff.
|
|
1950
|
+
|
|
1951
|
+
### When to use `cache.inputs.tasks: []`
|
|
1952
|
+
|
|
1953
|
+
When a `dependsOn` exists for ordering only, and the upstream's
|
|
1954
|
+
output doesn't influence this task's output. Common case: an
|
|
1955
|
+
integration test that depends on a dev server starting up but whose
|
|
1956
|
+
own inputs are just the test source.
|
|
1957
|
+
|
|
1958
|
+
```ts
|
|
1959
|
+
e2e: {
|
|
1960
|
+
dependsOn: ['dev'],
|
|
1961
|
+
exec: { command: 'playwright test' },
|
|
1962
|
+
cache: {
|
|
1963
|
+
inputs: {
|
|
1964
|
+
files: ['e2e/**'],
|
|
1965
|
+
tasks: [], // dev's hash does not affect e2e's cache identity
|
|
1966
|
+
},
|
|
1967
|
+
outputs: { files: ['playwright-report/**'] },
|
|
1968
|
+
},
|
|
1969
|
+
}
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
### When to use `--no-cache` instead of removing `cache`
|
|
1973
|
+
|
|
1974
|
+
`--no-cache` is a runtime flag, not a config change. Reach for it
|
|
1975
|
+
when:
|
|
1976
|
+
|
|
1977
|
+
- You suspect cache corruption and want a clean run.
|
|
1978
|
+
- You're benchmarking and need fresh timings.
|
|
1979
|
+
- You're forwarding one-off args and don't want to pollute the cache
|
|
1980
|
+
with a one-shot key (though note: forwarded args are already in
|
|
1981
|
+
the key, so they form their own entry anyway).
|
|
1982
|
+
|
|
1983
|
+
It applies to the whole run; per-task opt-out belongs in config (omit
|
|
1984
|
+
the `cache` block).
|
|
1985
|
+
|
|
1986
|
+
## Schema validation errors
|
|
1987
|
+
|
|
1988
|
+
The loader (`src/workspace/project-loader.ts`) validates at load time
|
|
1989
|
+
and surfaces `UserError` (clean output, no stack). Every field's
|
|
1990
|
+
accepted values and exact refusal text are recorded in
|
|
1991
|
+
`tests/contract/config-schema.json`, which a test regenerates from the
|
|
1992
|
+
validator and compares, so a change to either is deliberate
|
|
1993
|
+
(`design/versioning-1.0.md` § How the contract is held).
|
|
1994
|
+
|
|
1995
|
+
When the field a refusal names is in the file, the CLI prints the file
|
|
1996
|
+
at its line and column, relative to the current directory (a terminal
|
|
1997
|
+
opens it on click), and the lines up to it with a caret under the field
|
|
1998
|
+
(`util/config-frame.ts`):
|
|
1999
|
+
|
|
2000
|
+
```
|
|
2001
|
+
vx: packages/b/vx.config.ts:4:15: tasks.build.exec has unknown field "comand" (allowed: …) — did you mean command?
|
|
2002
|
+
|
|
2003
|
+
2 | tasks: {
|
|
2004
|
+
3 | build: {
|
|
2005
|
+
> 4 | exec: { comand: 'mkdir -p dist' },
|
|
2006
|
+
| ^
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
A field the file does not hold (a required one missing, or one a plugin
|
|
2010
|
+
set) prints the one line as before. The table below lists the messages
|
|
2011
|
+
a user meets most:
|
|
2012
|
+
|
|
2013
|
+
| Symptom | Cause |
|
|
2014
|
+
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
2015
|
+
| `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). |
|
|
2016
|
+
| `tasks must be an object keyed by task name` | `tasks` is not an object — an ARRAY included. |
|
|
2017
|
+
| `<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). |
|
|
2018
|
+
| `<level> has unknown field "<key>"` | Typo'd / unsupported key (see below). |
|
|
2019
|
+
| `<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). |
|
|
2020
|
+
| `<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. |
|
|
2021
|
+
| `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). |
|
|
2022
|
+
| `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). |
|
|
2023
|
+
| `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. |
|
|
2024
|
+
| `exec must be an object with a command string` | `exec` is malformed. |
|
|
2025
|
+
| `exec.command must be a non-empty string` | Forgot `command`, or an empty or whitespace-only string. |
|
|
2026
|
+
| `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. |
|
|
2027
|
+
| `exec.persistent must be an object (or omitted)` | Wrong shape. |
|
|
2028
|
+
| `exec.persistent.readyWhen must be a string regex` | Non-string `readyWhen`. |
|
|
2029
|
+
| `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. |
|
|
2030
|
+
| `cache is not allowed on a persistent task` | persistent + cache combined. |
|
|
2031
|
+
| `exec.interactive must be a boolean (or omitted)` | Wrong shape. |
|
|
2032
|
+
| `cache is not allowed on an interactive task` | interactive + cache combined: what it does depends on what is typed. |
|
|
2033
|
+
| `sandbox is not allowed on an interactive task` | interactive + sandbox combined. |
|
|
2034
|
+
| `persistent.readyWhen is not allowed on an interactive task` | vx reads none of an interactive task's output. |
|
|
2035
|
+
| `a task with no exec must declare dependsOn` | Group task with no edges. |
|
|
2036
|
+
| `a task with no exec writes nothing` | Group task with `cache.outputs` that names a file. |
|
|
2037
|
+
| `dependsOn must be an array of strings` | Wrong shape. |
|
|
2038
|
+
| `cache.inputs is required when cache is set` | Forgot `inputs`. |
|
|
2039
|
+
| `cache.inputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
|
|
2040
|
+
| `cache.inputs.files must be an array` | Wrong shape. |
|
|
2041
|
+
| `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. |
|
|
2042
|
+
| `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. |
|
|
2043
|
+
| `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
|
|
2044
|
+
| `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
|
|
2045
|
+
| `cache.outputs is required when cache is set` | Forgot `outputs`. |
|
|
2046
|
+
| `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
|
|
2047
|
+
| `cache.outputs.files must be an array` | Wrong shape. |
|
|
2048
|
+
| `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
|
|
2049
|
+
| `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. |
|
|
2050
|
+
| `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). |
|
|
2051
|
+
| `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. |
|
|
2052
|
+
| `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. |
|
|
2053
|
+
| `cache.inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
|
|
2054
|
+
| `exec.timeout: <n> ms exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
|
|
2055
|
+
| `description must be a string` | Non-string description. |
|
|
2056
|
+
| `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. |
|
|
2057
|
+
|
|
2058
|
+
**Unknown fields are rejected**, not ignored, at every object level —
|
|
2059
|
+
the project's top level (`tasks`), the task itself, `exec`, `exec.env`,
|
|
2060
|
+
`exec.persistent`, `exec.sandbox` and its `allow` /
|
|
2061
|
+
`deny` / `ignore` blocks, `cache`, `cache.inputs`, and `cache.outputs`
|
|
2062
|
+
(`tests/schema-unknown-keys.test.ts` walks every one) — and at the top
|
|
2063
|
+
of `vx.workspace.ts`,
|
|
2064
|
+
where `plugin:` (singular) would otherwise declare no plugins and run
|
|
2065
|
+
the workspace bare. A silently-dropped
|
|
2066
|
+
`workspaceFile` (singular) or `timeoutMs` would make the task hash as
|
|
2067
|
+
though the field had never been written, so vx would replay an artifact
|
|
2068
|
+
built from different inputs; a silently-dropped `env: { set: … }` would
|
|
2069
|
+
run the task without the variables it was written to have. The error
|
|
2070
|
+
names the offending key, lists what that level accepts, and adds the
|
|
2071
|
+
nearest accepted spelling when one is within two edits:
|
|
2072
|
+
`tasks.build.exec.env has unknown field "passthrough" (allowed: define,
|
|
2073
|
+
passThrough, secret) — did you mean passThrough?`. A field another runner spells
|
|
2074
|
+
on the task names vx's home for it instead (D-37): Turbo's `outputs`,
|
|
2075
|
+
`inputs`, `env`, `passThroughEnv`, `persistent`, `outputLogs`, `interactive`
|
|
2076
|
+
(`exec.interactive`) and `with`, Nx's target `executor`, `options`, `continuous` (D-49), `cwd`,
|
|
2077
|
+
`parallelism` and `configurations` (D-89), and a `command`
|
|
2078
|
+
(`cmd`, `script`) on the task or `cmd` on `exec`, and on `exec` Nx
|
|
2079
|
+
run-commands' `cwd`, `args`, `commands`, `parallel` and `shell`
|
|
2080
|
+
(D-100), and in a `cache` block wireit's `files` / `output`,
|
|
2081
|
+
`env`, `dependencies`, `enabled`, and `globs` / `include` / `patterns` /
|
|
2082
|
+
`exclude` / `ignore` under `inputs` or `outputs` (D-118). So `outputs` on a task
|
|
2083
|
+
ends `— vx spells it cache.outputs.files` in code quotes. A `cache` that
|
|
2084
|
+
is no object (Turbo's `cache: false`) and a `persistent` that is none
|
|
2085
|
+
(`true`) name the shape to write.
|
|
2086
|
+
|
|
2087
|
+
Workspace-discovery errors (`src/workspace/workspace.ts`):
|
|
2088
|
+
|
|
2089
|
+
| Symptom | Cause |
|
|
2090
|
+
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2091
|
+
| `failed to parse <file>: <why>` | A `package.json` / `pnpm-workspace.yaml` is not valid. |
|
|
2092
|
+
| `<file>: packages must be an array of glob strings` | `pnpm-workspace.yaml` `packages:` is a bare string, etc. |
|
|
2093
|
+
| `<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. |
|
|
2094
|
+
| `<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. |
|
|
2095
|
+
| `<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. |
|
|
2096
|
+
| `<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). |
|
|
2097
|
+
| `<file>: workspaces must be an array of glob strings` | `package.json` `workspaces` holds a non-string entry. |
|
|
2098
|
+
| `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. |
|
|
2099
|
+
| `<file>: workspaces.packages must be an array of glob strings` | The yarn-legacy `workspaces: { packages: [...] }` form holds a non-string entry. |
|
|
2100
|
+
| `<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"]`. |
|
|
2101
|
+
|
|
2102
|
+
Workspace-config errors:
|
|
2103
|
+
|
|
2104
|
+
| Symptom | Cause |
|
|
2105
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2106
|
+
| `concurrency must be a positive integer` | `concurrency` is negative, zero, NaN, ... |
|
|
2107
|
+
| `timeout must be a positive integer (milliseconds)` | Workspace `timeout` is ≤ 0, NaN, or not an int. |
|
|
2108
|
+
| `cacheDir must be a string` | Wrong shape. |
|
|
2109
|
+
| `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`. |
|
|
2110
|
+
| `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. |
|
|
2111
|
+
| `cacheRetention must be { olderThan?: '30d', maxSize?: '10G' }` | Not an object. |
|
|
2112
|
+
| `cacheRetention names neither olderThan nor maxSize` | An empty policy would evict nothing and read as one. |
|
|
2113
|
+
| `cacheRetention.olderThan must be a duration like '30d', '12h', '90m' or '45s'` | The `vx cache prune --older-than` spelling, or not a string. |
|
|
2114
|
+
| `cacheRetention.maxSize must be a size like '10G', '500MB' or '64KB'` | The `vx cache prune --max-size` spelling (no fractions), or not a string. |
|
|
2115
|
+
| `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. |
|
|
2116
|
+
| `cacheRetention.maxSize of 0 evicts every entry after every run` | The same, for the size bound. |
|
|
2117
|
+
| `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. |
|
|
2118
|
+
| `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). |
|
|
2119
|
+
| `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. |
|
|
2120
|
+
| `rules must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }` | Not an object. |
|
|
2121
|
+
| `rules.<name> must be true or false` | A rule set to something other than a boolean (`'off'`, `0`). |
|
|
2122
|
+
| `plugins must be an array of plugin objects` | Wrong shape. |
|
|
2123
|
+
| `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). |
|
|
2124
|
+
| `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. |
|
|
2125
|
+
| `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). |
|
|
2126
|
+
| `plugins[<i>].name overrides the package name` | A `name` set over `definePlugin`'s result; drop the field. |
|
|
2127
|
+
| `plugins[<i>].<capability> of plugin '<name>' must be a function` | A capability key holding something that is not callable. |
|
|
2128
|
+
| `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. |
|
|
2129
|
+
| `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. |
|
|
2130
|
+
| `<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). |
|
|
2131
|
+
| `plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed` | A plugin verb the dispatcher matches first; it could never run. |
|
|
2132
|
+
| `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. |
|
|
2133
|
+
| `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. |
|
|
2134
|
+
| `plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function }` | A fingerprint claim without its file list or its `affected` answer. |
|
|
2135
|
+
| `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). |
|
|
2136
|
+
| `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. |
|
|
2137
|
+
| `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). |
|