@vzn/vx 0.0.634 → 0.0.636

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +14 -2
  3. package/docs/cli.md +94 -36
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +4 -3
  6. package/docs/patterns.md +8 -7
  7. package/docs/schema.md +73 -3
  8. package/docs/security.md +7 -1
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/last.json +5 -1
  13. package/schemas/show.json +8 -0
  14. package/schemas/why.json +4 -1
  15. package/src/cache/layered-cache.ts +3 -1
  16. package/src/cli/help.ts +3 -1
  17. package/src/cli/last.ts +9 -1
  18. package/src/cli/plugin-templates.ts +1 -1
  19. package/src/cli/run.ts +35 -7
  20. package/src/cli/select.ts +17 -11
  21. package/src/cli/show.ts +2 -2
  22. package/src/config.ts +36 -3
  23. package/src/graph/scheduler.ts +14 -3
  24. package/src/graph/task-graph.ts +22 -5
  25. package/src/index.ts +7 -1
  26. package/src/orchestrator/execute-task.ts +62 -6
  27. package/src/orchestrator/framed-output.ts +27 -1
  28. package/src/orchestrator/history.ts +75 -1
  29. package/src/orchestrator/index.ts +1 -0
  30. package/src/orchestrator/options.ts +2 -0
  31. package/src/orchestrator/path-links.ts +5 -3
  32. package/src/orchestrator/plugin.ts +3 -1
  33. package/src/orchestrator/prepare.ts +17 -14
  34. package/src/orchestrator/run-failures.ts +20 -3
  35. package/src/orchestrator/run.ts +25 -2
  36. package/src/orchestrator/sandbox-fix.ts +123 -0
  37. package/src/orchestrator/task-hash.ts +7 -1
  38. package/src/workspace/affected.ts +16 -4
  39. package/src/workspace/config-schema.ts +85 -4
  40. package/src/workspace/index.ts +2 -0
  41. package/src/workspace/migration.ts +51 -4
  42. package/src/workspace/project-loader.ts +3 -1
package/docs/schema.md CHANGED
@@ -704,7 +704,65 @@ Semantics:
704
704
  interface CacheConfig {
705
705
  inputs: CacheInputs // required when `cache` is provided
706
706
  outputs: CacheOutputs // required when `cache` is provided
707
+ inputStability?: InputStability // optional
707
708
  }
709
+
710
+ interface InputStability {
711
+ strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
712
+ severity?: 'warn' | 'error' // default 'warn'
713
+ }
714
+ ```
715
+
716
+ `inputStability` says what a write to an input while the command runs
717
+ means. `strategy` says which writes count:
718
+
719
+ - `'immutable'` (the default, on CI too): any write. An edit undone
720
+ mid-run, or another task changing the file and putting it back, cannot
721
+ be told from no edit.
722
+ - `'mutable'`: a file rewritten to the bytes the key folded counts as
723
+ unchanged, for a task that rewrites its own input identically
724
+ (tsdown's `package.json`); different bytes still count.
725
+ - `'unchecked'`: none. The entry is saved under the key taken before the
726
+ command, whatever the command did to its inputs; `severity` is ignored.
727
+ A key that no longer held before the command started still withholds
728
+ it.
729
+
730
+ A write that counts withholds the entry, and `severity` says what else
731
+ happens: `'warn'` (the default) keeps the result, says why on the status
732
+ line, and the run goes on; `'error'` fails the task, so the run fails.
733
+ Either way, every task that ran over the task's outputs, down the graph,
734
+ withholds its save too, and a task of any project that declares the
735
+ moved file as an input withholds its own.
736
+
737
+ Each field is resolved on its own, highest first:
738
+
739
+ 1. `VX_INPUT_STABILITY` (`immutable`, `mutable`, `unchecked`) and
740
+ `VX_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
741
+ (a CI job that wants `error` sets it without editing configs); any
742
+ other value is refused before a task runs;
743
+ 2. the task's own `cache.inputStability`;
744
+ 3. the workspace's `inputStability` (`vx.workspace.ts`);
745
+ 4. the default, `'immutable'` and `'warn'`.
746
+
747
+ Never folded into a cache key.
748
+
749
+ ```ts
750
+ import { defineProject } from '@vzn/vx/config'
751
+
752
+ export default defineProject({
753
+ tasks: {
754
+ build: {
755
+ // tsdown rewrites package.json with the same bytes on every build;
756
+ // different bytes fail the run rather than rebuild next time.
757
+ exec: { command: 'tsdown' },
758
+ cache: {
759
+ inputs: { files: ['src/**', 'package.json'] },
760
+ outputs: { files: ['dist/**'] },
761
+ inputStability: { strategy: 'mutable', severity: 'error' },
762
+ },
763
+ },
764
+ },
765
+ })
708
766
  ```
709
767
 
710
768
  **Caching is opt-in.** Omit `cache` and the task always runs (no read,
@@ -1535,6 +1593,7 @@ export default defineWorkspace({
1535
1593
  cacheScope: 'trusted',
1536
1594
  rules: { exclusiveOutputs: true, upfrontKeys: true },
1537
1595
  schedule: 'most-work',
1596
+ inputStability: { strategy: 'immutable', severity: 'warn' },
1538
1597
  plugins: [otel()],
1539
1598
  })
1540
1599
  ```
@@ -1557,8 +1616,10 @@ interface WorkspaceConfig {
1557
1616
  rules?: WorkspaceRules
1558
1617
  /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1559
1618
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1619
+ /** Every task's cache.inputStability default: { strategy, severity }. */
1620
+ inputStability?: InputStability
1560
1621
  /** Run-level plugins (cache / executor / telemetry capabilities). */
1561
- plugins?: readonly Plugin[]
1622
+ plugins?: readonly (Plugin | undefined | null | false)[]
1562
1623
  }
1563
1624
 
1564
1625
  interface WorkspaceRules {
@@ -1630,7 +1691,7 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1630
1691
  each other. A scope's key is derived from the task key, so every
1631
1692
  remote wire stores it unchanged. A clamp on `--cache`, never a
1632
1693
  widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1633
- the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
1694
+ the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
1634
1695
  boundary: a run holding a write credential can write any key, so only
1635
1696
  a cache server that scopes writes by token can refuse one
1636
1697
  (`docs/security.md` § Cache poisoning). Not folded into any cache key.
@@ -1645,6 +1706,10 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1645
1706
  their ties. The default costs what it did; another strategy is
1646
1707
  computed only when named. Any other value is refused. Never folded
1647
1708
  into a cache key.
1709
+ - **`inputStability`** — the `cache.inputStability` of every task that
1710
+ does not set its own field, same shape (`{ strategy, severity }`, §
1711
+ `cache`). The env vars still override it for the run. Never folded
1712
+ into a cache key.
1648
1713
  - **`rules`** — checks on the task graph, each on unless set to
1649
1714
  `false`. Turning one off allows a shape vx runs correctly but more
1650
1715
  slowly; never folded into a cache key. A value that is not a boolean,
@@ -1682,7 +1747,9 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
1682
1747
  Declaration order is precedence: every `executor` is consulted in
1683
1748
  order per task (first to accept runs it; what all decline runs
1684
1749
  locally); every `cache` layer is chained (lookup walks, save reaches
1685
- all, the local store last). Each entry is a `VxPlugin` contributing
1750
+ all, the local store last). An `undefined`, `null` or `false` entry
1751
+ is skipped, as Vite skips one: a factory that declines (`github()` off
1752
+ Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
1686
1753
  any subset of the fourteen hooks, in pipeline order: `config` (edit
1687
1754
  the workspace config before anything derives from it), `discover`
1688
1755
  (name directories to make projects beyond the members), `project`
@@ -2057,6 +2124,9 @@ a user meets most:
2057
2124
  | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2058
2125
  | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2059
2126
  | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2127
+ | `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
2128
+ | `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
2129
+ | `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
2060
2130
  | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2061
2131
  | `cache.outputs.files must be an array` | Wrong shape. |
2062
2132
  | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
package/docs/security.md CHANGED
@@ -42,7 +42,7 @@ namespace), or none.
42
42
  `cacheScope` is the client half. `'pr-123'` reads its own keys,
43
43
  then the trusted ones, and writes only its own, which `github()` sets on
44
44
  Actions (`pr-<n>`, `ref-<name>`; the default branch stays trusted)
45
- and `VX_CACHE_SCOPE` sets on any CI.
45
+ and `VX_CACHE_SCOPE` or `--cache-scope` sets on any CI.
46
46
  It keeps honest runs apart and stops a non-hermetic PR build from
47
47
  landing where main reads; it is not a boundary, since the PR's own
48
48
  config decides it and any run holding a write credential can write any
@@ -89,6 +89,12 @@ writes whatever its config says. Never give the write secret to a
89
89
  out a pull request's code: it runs with main's ref and secrets, and
90
90
  `github()` scopes it `pr-<n>` only on the client side.
91
91
 
92
+ A boundary that holds whoever can write the store is a signing cache
93
+ plugin: main's CI signs each upload, and a reader refuses, as it streams,
94
+ a download no trusted key signed, so the hit is a miss. Core needs no
95
+ change for it; the plugins guide (§ Your own cache › Signed artifacts) has a
96
+ complete one, run by `tests/signed-cache-guide.unsafe.test.ts`.
97
+
92
98
  ## What vx checks on bytes it did not write
93
99
 
94
100
  Every artifact that arrives from a remote, and every one read back from
package/launcher.cjs CHANGED
File without changes
package/launcher.sh ADDED
@@ -0,0 +1,50 @@
1
+ #!/bin/sh
2
+ # npm launcher for a vx command: the published package's `bin`. It execs the
3
+ # platform package's prebuilt binary straight from the shell, since Node's
4
+ # own start was ~40 ms of every `vx` call made through node_modules/.bin
5
+ # (45-53 ms against 12-14 ms for `vx --version`, 2026-10-12). Anything it
6
+ # cannot settle (no sibling platform package, musl, Yarn PnP, an unknown
7
+ # platform) goes to launcher.cjs beside it, which resolves through Node and
8
+ # owns the fallbacks and the error messages.
9
+
10
+ # The real file: npm's .bin entry is a symlink to it. Parameter expansion,
11
+ # not dirname/basename, and no `cd`: each fork is a millisecond, and the
12
+ # kernel resolves `<dir>/..` through a symlinked directory (pnpm's
13
+ # node_modules/@vzn/vx) to its real parent on its own.
14
+ self=$0
15
+ hops=0
16
+ while [ -L "$self" ] && [ "$hops" -lt 40 ]; do
17
+ hops=$((hops + 1))
18
+ target=$(readlink "$self")
19
+ case $target in
20
+ /*) self=$target ;;
21
+ *) self=${self%/*}/$target ;;
22
+ esac
23
+ done
24
+ case $self in
25
+ */*) here=${self%/*} ;;
26
+ *) here=. ;;
27
+ esac
28
+ base=${here##*/}
29
+ scope=${here%/*}
30
+ scope=${scope##*/}
31
+
32
+ key=
33
+ loader=
34
+ case $(uname -sm) in
35
+ 'Linux x86_64') key=linux-x64 loader=/lib64/ld-linux-x86-64.so.2 ;;
36
+ 'Linux aarch64' | 'Linux arm64') key=linux-arm64 loader=/lib/ld-linux-aarch64.so.1 ;;
37
+ 'Darwin x86_64') key=darwin-x64 ;;
38
+ 'Darwin arm64') key=darwin-arm64 ;;
39
+ esac
40
+
41
+ # The platform package sits beside this one under npm, pnpm and Bun
42
+ # (node_modules/@vzn/vx-<key>), or under this package's own node_modules
43
+ # when npm could not hoist it. The glibc binary needs its loader (musl
44
+ # has none); launcher.cjs names that cause.
45
+ if [ -n "$key" ] && { [ -z "$loader" ] || [ -e "$loader" ]; }; then
46
+ for bin in "$here/../$base-$key/$base" "$here/node_modules/$scope/$base-$key/$base"; do
47
+ if [ -x "$bin" ]; then exec "$bin" "$@"; fi
48
+ done
49
+ fi
50
+ exec node "$here/launcher.cjs" "$@"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.634",
4
- "description": "The fastest task runner and build cache for JavaScript monorepos.",
3
+ "version": "0.0.636",
4
+ "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -15,16 +15,16 @@
15
15
  },
16
16
  "types": "./src/index.ts",
17
17
  "bin": {
18
- "vx": "./launcher.cjs"
18
+ "vx": "./launcher.sh"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.634",
25
- "@vzn/vx-linux-arm64": "0.0.634",
26
- "@vzn/vx-darwin-x64": "0.0.634",
27
- "@vzn/vx-darwin-arm64": "0.0.634"
24
+ "@vzn/vx-linux-x64": "0.0.636",
25
+ "@vzn/vx-linux-arm64": "0.0.636",
26
+ "@vzn/vx-darwin-x64": "0.0.636",
27
+ "@vzn/vx-darwin-arm64": "0.0.636"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -42,6 +42,7 @@
42
42
  "docs/patterns.md",
43
43
  "docs/security.md",
44
44
  "docs/features.md",
45
+ "launcher.sh",
45
46
  "launcher.cjs",
46
47
  "README.md",
47
48
  "LICENSE"
@@ -64,6 +65,14 @@
64
65
  "nx",
65
66
  "bun",
66
67
  "workspaces",
67
- "ci"
68
+ "ci",
69
+ "task-orchestration",
70
+ "turborepo-alternative",
71
+ "nx-alternative",
72
+ "remote-cache",
73
+ "sandbox",
74
+ "incremental-build",
75
+ "mcp",
76
+ "pnpm"
68
77
  ]
69
78
  }
package/schemas/last.json CHANGED
@@ -170,7 +170,7 @@
170
170
  },
171
171
  "locations": {
172
172
  "type": "array",
173
- "description": "on a failed row: the existing files its output names, in order of first appearance",
173
+ "description": "on a failed row: the existing files its output names, in order of first appearance; a file it also names with a line drops its bare mention",
174
174
  "items": {
175
175
  "type": "object",
176
176
  "additionalProperties": false,
@@ -181,6 +181,10 @@
181
181
  "col": { "type": "integer" }
182
182
  }
183
183
  }
184
+ },
185
+ "fixedIn": {
186
+ "type": "string",
187
+ "description": "on a failed row: the first later run where this task passed (ran or hit); absent while it has not"
184
188
  }
185
189
  }
186
190
  }
package/schemas/show.json CHANGED
@@ -115,6 +115,14 @@
115
115
  "files": { "$ref": "#/$defs/stringList" },
116
116
  "workspaceFiles": { "$ref": "#/$defs/stringList" }
117
117
  }
118
+ },
119
+ "inputStability": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "strategy": { "enum": ["immutable", "mutable", "unchecked"] },
124
+ "severity": { "enum": ["warn", "error"] }
125
+ }
118
126
  }
119
127
  }
120
128
  }
package/schemas/why.json CHANGED
@@ -77,7 +77,10 @@
77
77
  "type": ["boolean", "null"],
78
78
  "description": "on a hit: true when outputs were restored, false when up-to-date"
79
79
  },
80
- "cached": { "type": ["boolean", "null"] },
80
+ "cached": {
81
+ "type": ["boolean", "null"],
82
+ "description": "whether the task declares a cache; not whether this run hit it"
83
+ },
81
84
  "startedAt": { "type": "integer", "description": "epoch ms" }
82
85
  }
83
86
  },
@@ -76,7 +76,9 @@ export interface RemoteCacheLayer {
76
76
  * straight into the local cache — an HTTP wire returns its `fetch`
77
77
  * `Response`, a chunked wire `new Response(stream)`, bytes in hand
78
78
  * `new Blob([bytes])`. `durationMs` is the producing task's duration
79
- * when the wire carries it.
79
+ * when the wire carries it. A body that errors before its end stores
80
+ * nothing and the read is a miss, so a layer can check bytes as they pass
81
+ * (a digest, a signature) and refuse at the end.
80
82
  */
81
83
  get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
82
84
  /**
package/src/cli/help.ts CHANGED
@@ -140,7 +140,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
140
140
  ' (layer = local|remote, flags ⊆ rw). A named layer is set EXACTLY',
141
141
  ' to its flags; unnamed layers keep their value. Examples:',
142
142
  ' local:rw,remote:r (remote read-only), remote: (remote off),',
143
- ' local:r (local read-only).',
143
+ ' local:r (local read-only). Without it, VX_CACHE holds the spec.',
144
+ " --cache-scope <name> This run's cacheScope: trusted | read-only | a scope (pr-123).",
145
+ ' Beats VX_CACHE_SCOPE and the workspace cacheScope.',
144
146
  ' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
145
147
  ' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
146
148
  ' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
package/src/cli/last.ts CHANGED
@@ -384,7 +384,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
384
384
  )
385
385
  const tasks = (detail?.tasks ?? []).map((t) => {
386
386
  const f = failures.get(`${t.project}#${t.task}`)
387
- return f === undefined ? t : { ...t, output: f.output, locations: f.locations }
387
+ return f === undefined
388
+ ? t
389
+ : {
390
+ ...t,
391
+ output: f.output,
392
+ locations: f.locations,
393
+ ...(f.sandboxFix !== undefined ? { sandboxFix: f.sandboxFix } : {}),
394
+ ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
395
+ }
388
396
  })
389
397
  process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
390
398
  return 0
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
10
10
  },
11
11
  cache: {
12
12
  plugin:
13
- "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir, rename } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then renamed: a reader never sees half an artifact.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n await rename(tmp, path.join(this.endpoint, hash))\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
14
14
  test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
16
16
  commands: {
package/src/cli/run.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
- import { findWorkspaceRoot } from '../workspace/index.js'
4
+ import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
5
5
  import { translateForeign } from './foreign-flags.js'
6
6
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
7
7
  import {
@@ -52,6 +52,8 @@ export interface RunArgs {
52
52
  remoteRequested?: boolean
53
53
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
54
54
  cacheDir: string | undefined
55
+ /** `--cache-scope <name>`: this run's `cacheScope`, above `VX_CACHE_SCOPE` and the workspace's. */
56
+ cacheScope?: string
55
57
  frozen: boolean
56
58
  /**
57
59
  * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
@@ -158,6 +160,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
158
160
  // beats `--force`, and `--cache` is the base both may override.
159
161
  let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
160
162
  let cacheSpecError: string | undefined
163
+ let cacheFlag = false
161
164
  let noCacheFlag = false
162
165
  let forceFlag = false
163
166
 
@@ -282,7 +285,13 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
282
285
  }
283
286
  }
284
287
  out.cacheDir = v
288
+ } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
289
+ const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
290
+ if (v === undefined || !isCacheScope(v))
291
+ return { ...out, error: cacheScopeError('--cache-scope') }
292
+ out.cacheScope = v
285
293
  } else if (a === '--cache' || a?.startsWith('--cache=')) {
294
+ cacheFlag = true
286
295
  const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
287
296
  if (v === undefined)
288
297
  return {
@@ -407,6 +416,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
407
416
  if (cacheSpecError !== undefined) {
408
417
  return { ...out, error: cacheSpecError }
409
418
  }
419
+ // `VX_CACHE` is the spec a run takes when no `--cache` names one.
420
+ const envSpec = process.env['VX_CACHE'] || undefined
421
+ if (!cacheFlag && envSpec !== undefined) {
422
+ if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
423
+ return {
424
+ ...out,
425
+ error: `VX_CACHE needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
426
+ }
427
+ try {
428
+ cachePolicy = parseCachePolicy(envSpec, cachePolicy)
429
+ } catch (err) {
430
+ return { ...out, error: `VX_CACHE: ${err instanceof Error ? err.message : String(err)}` }
431
+ }
432
+ if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
433
+ }
410
434
  // Precedence: --no-cache (all off) beats --force (reads off, writes
411
435
  // kept), both layered on top of any --cache spec.
412
436
  if (noCacheFlag) {
@@ -479,7 +503,9 @@ function onCI(v: string | undefined): boolean {
479
503
  async function scopeFilters(
480
504
  parsed: RunArgs,
481
505
  cwd: string,
482
- ): Promise<{ filterStrings: string[]; affectedFilter?: string } | { error: string }> {
506
+ ): Promise<
507
+ { filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
508
+ > {
483
509
  // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
484
510
  // changed projects AND their dependents — what a CI gate must run, what
485
511
  // the flag's name says, what the guides promised while the sugar was the
@@ -523,7 +549,7 @@ export async function resolveRunOptions(
523
549
  verb: 'run' | 'watch' = 'run',
524
550
  /** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
525
551
  scopeOut?: { affectedBase?: string },
526
- ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
552
+ ): Promise<RunOptions | { error: string; code?: string } | { nothingSelected: string }> {
527
553
  for (const t of tasks) {
528
554
  const idx = t.indexOf('#')
529
555
  if (idx >= 0) {
@@ -566,7 +592,7 @@ export async function resolveRunOptions(
566
592
  affectedFilter,
567
593
  )
568
594
  : undefined
569
- if (resolved !== undefined && 'error' in resolved) return { error: resolved.error }
595
+ if (resolved !== undefined && 'error' in resolved) return resolved
570
596
  if (bareTasks.length === 0) {
571
597
  projects = undefined
572
598
  } else if (resolved !== undefined) {
@@ -609,6 +635,7 @@ export async function resolveRunOptions(
609
635
  tasks: [...tasks],
610
636
  cache: parsed.cache,
611
637
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
638
+ ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
612
639
  // A laptop reads the shared cache and never writes it (owner,
613
640
  // 2026-10-03); naming the remote in `--cache` is asking to.
614
641
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
@@ -696,11 +723,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
696
723
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
697
724
  // the menu listed every project and the anchored pick ran outside it.
698
725
  const scope = await scopeFilters(parsed, cwd)
699
- if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
726
+ if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
700
727
  let only: ReadonlySet<string> | undefined
701
728
  if (scope.filterStrings.length > 0) {
702
729
  const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
703
- if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
730
+ if ('error' in selected)
731
+ return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
704
732
  if ('empty' in selected) {
705
733
  process.stderr.write(`vx run: ${selected.empty}\n`)
706
734
  return 0
@@ -716,7 +744,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
716
744
 
717
745
  const scopeOut: { affectedBase?: string } = {}
718
746
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
719
- if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
747
+ if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
720
748
  if ('nothingSelected' in resolved) {
721
749
  process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
722
750
  return 0
package/src/cli/select.ts CHANGED
@@ -197,7 +197,7 @@ export type FilterResolution =
197
197
  /** What the other includes selected (`RunOptions.selectedOutright`). */
198
198
  outright?: string[]
199
199
  }
200
- | { error: string }
200
+ | Refusal
201
201
  | { empty: string }
202
202
 
203
203
  /**
@@ -239,16 +239,23 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
239
239
  return out
240
240
  }
241
241
 
242
+ /** A refusal a resolver returns; `code` is the thrown one's own, when it has one. */
243
+ type Refusal = { error: string; code?: string }
244
+
245
+ function refusalOf(err: unknown): Refusal {
246
+ if (!(err instanceof Error)) return { error: String(err) }
247
+ return isUserError(err) && err.code !== 'VX_E_REFUSED'
248
+ ? { error: err.message, code: err.code }
249
+ : { error: err.message }
250
+ }
251
+
242
252
  /**
243
253
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
244
254
  * projects and their dependents. An empty base is the workspace's
245
255
  * `affectedBase` (or a plugin's `config` stage, from nx.json's
246
256
  * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
247
257
  */
248
- export async function affectedFilterFor(
249
- cwd: string,
250
- affected: string,
251
- ): Promise<string | { error: string }> {
258
+ export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
252
259
  const root = await findWorkspaceRoot(cwd)
253
260
  let base = affected
254
261
  if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
@@ -257,7 +264,7 @@ export async function affectedFilterFor(
257
264
  base = await defaultAffectedBase(root)
258
265
  } catch (err) {
259
266
  if (!isUserError(err)) throw err
260
- return { error: err.message }
267
+ return refusalOf(err)
261
268
  }
262
269
  }
263
270
  return `...[${base}]`
@@ -276,7 +283,7 @@ export async function resolveFilters(
276
283
  try {
277
284
  parsed = raw.map((r) => parseFilter(r, root))
278
285
  } catch (err) {
279
- return { error: err instanceof Error ? err.message : String(err) }
286
+ return refusalOf(err)
280
287
  }
281
288
  // `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
282
289
  const walksGraph = parsed.some(
@@ -299,7 +306,7 @@ export async function resolveFilters(
299
306
  try {
300
307
  edges = taskEdgesFrom(await stagedOnce())
301
308
  } catch (err) {
302
- return { error: err instanceof Error ? err.message : String(err) }
309
+ return refusalOf(err)
303
310
  }
304
311
  }
305
312
  // A tag lives in the config, so a `tag:` selector needs the staged load
@@ -309,7 +316,7 @@ export async function resolveFilters(
309
316
  try {
310
317
  tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
311
318
  } catch (err) {
312
- return { error: err instanceof Error ? err.message : String(err) }
319
+ return refusalOf(err)
313
320
  }
314
321
  }
315
322
  const graph = buildPackageGraph(projects, edges)
@@ -353,8 +360,7 @@ export async function resolveFilters(
353
360
  }
354
361
  affectedByFilter.set(f, names)
355
362
  } catch (err) {
356
- const msg = err instanceof Error ? err.message : String(err)
357
- return { error: msg }
363
+ return refusalOf(err)
358
364
  }
359
365
  }
360
366
 
package/src/cli/show.ts CHANGED
@@ -145,12 +145,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
145
145
  let affected: string | undefined
146
146
  if (parsed.affected !== undefined) {
147
147
  const f = await affectedFilterFor(process.cwd(), parsed.affected)
148
- if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
148
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
149
149
  affected = f
150
150
  raw.unshift(f)
151
151
  }
152
152
  const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
153
- if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
153
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
154
154
  if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
155
155
  selected = new Set('empty' in r ? [] : r.names)
156
156
  if ('names' in r && r.affected !== undefined)