@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,183 @@
1
+ // zstd framing for artifacts: the declared-size gate that refuses a
2
+ // decompression bomb before it can expand, and the bounded one-call /
3
+ // streamed decoders the store and the ingest path share.
4
+
5
+ import { CorruptArtifactError } from './layer.js'
6
+
7
+ /**
8
+ * A decompressed artifact above this is refused as a zstd bomb rather than
9
+ * expanded into memory. 2 GiB comfortably exceeds any real build output while
10
+ * bounding a malicious/compromised remote's ability to OOM a victim who takes
11
+ * a cache hit. The default of `Cache`'s `artifactCeiling`, the one seam that
12
+ * lowers it (for a test: no test can produce 2 GiB of output).
13
+ */
14
+ export const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
15
+
16
+ /**
17
+ * Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
18
+ * decompressing. Returns null when the frame omits it (streaming frames) or
19
+ * the header is too short to parse. vx's own producer (single-shot
20
+ * `Bun.zstdCompress` of a known buffer) always writes it, so an artifact that
21
+ * declares an enormous size can be rejected before a byte is allocated.
22
+ *
23
+ * Exported for tests: pins the per-`fcsFlag` byte layouts (incl. the 2-byte
24
+ * `+256` adjustment and the `dictIdFlag` offset) that a bomb-refusal e2e can't
25
+ * discriminate (their max declarable sizes sit below the ceiling).
26
+ */
27
+ export function zstdContentSize(b: Uint8Array): bigint | null {
28
+ if (b.length < 5) return null
29
+ // Magic_Number 0xFD2FB528, little-endian.
30
+ if (b[0] !== 0x28 || b[1] !== 0xb5 || b[2] !== 0x2f || b[3] !== 0xfd) return null
31
+ const desc = b[4]!
32
+ const fcsFlag = desc >> 6
33
+ const singleSegment = (desc >> 5) & 1
34
+ const dictIdFlag = desc & 3
35
+ let off = 5
36
+ if (singleSegment === 0) off += 1 // Window_Descriptor byte
37
+ off += dictIdFlag === 3 ? 4 : dictIdFlag // Dictionary_ID: 0,1,2,4 bytes
38
+ let fcsSize: number
39
+ if (fcsFlag === 0) fcsSize = singleSegment === 1 ? 1 : 0
40
+ else if (fcsFlag === 1) fcsSize = 2
41
+ else if (fcsFlag === 2) fcsSize = 4
42
+ else fcsSize = 8
43
+ if (fcsSize === 0 || b.length < off + fcsSize) return null
44
+ let v = 0n
45
+ for (let i = 0; i < fcsSize; i++) v |= BigInt(b[off + i]!) << BigInt(8 * i)
46
+ if (fcsSize === 2) v += 256n // per spec, the 2-byte field stores value − 256
47
+ return v
48
+ }
49
+
50
+ /**
51
+ * Decompress a zstd artifact that DECLARES its content size, with a hard
52
+ * output ceiling: refused before a byte is allocated when the declaration
53
+ * is over the cap. The re-check on the actual length below is a backstop
54
+ * against a decoder that stops validating the declaration, NOT a second
55
+ * live layer — see its comment. A frame with no
56
+ * declaration (a streamed producer's — vx's own, above 4 MiB) never comes
57
+ * here: `decodedTar` decodes it as a stream under the running count, so
58
+ * a sizeless bomb has nowhere to expand.
59
+ */
60
+ async function zstdDecompressBounded(
61
+ compressed: Uint8Array,
62
+ hash: string,
63
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
64
+ ): Promise<Uint8Array> {
65
+ assertDeclaredSize(compressed, hash, cap)
66
+ const out = await Bun.zstdDecompress(compressed)
67
+ // UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
68
+ // above refuses any frame DECLARING more than the cap, and Bun refuses a
69
+ // frame whose declaration disagrees with its body ("Decompression
70
+ // failed" on a forged Frame_Content_Size, measured item 487) — so a frame
71
+ // that gets here declared <= cap and produced exactly that. The only way
72
+ // this fires again is a decoder that stops validating the declaration.
73
+ // That is what it is for; it is not a second live layer, and no test can
74
+ // reach it.
75
+ if (out.length > cap) {
76
+ throw new CorruptArtifactError(hash, `decompressed to ${out.length} bytes (> ${cap} cap)`)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /** The pre-decompress half of the ceiling: the frame header's own claim, when it makes one. */
82
+ function assertDeclaredSize(
83
+ compressed: Uint8Array,
84
+ hash: string,
85
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
86
+ ): bigint | null {
87
+ const declared = zstdContentSize(compressed)
88
+ if (declared !== null && declared > BigInt(cap)) {
89
+ throw new CorruptArtifactError(hash, `declares ${declared} decompressed bytes (> ${cap} cap)`)
90
+ }
91
+ return declared
92
+ }
93
+
94
+ /** Compressed artifacts above this size are decoded as a stream, on restore and on ingest. */
95
+ export const STREAM_DECODE_FROM = 4 * 1024 * 1024
96
+
97
+ /** Collect a byte stream; only ever used where the seam wants bytes. */
98
+ export async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
99
+ return new Uint8Array(await new Response(stream).arrayBuffer())
100
+ }
101
+
102
+ /**
103
+ * Bun's `CompressionStream` types do not satisfy `pipeThrough`'s pair
104
+ * (its readable side is typed `NonSharedUint8Array`); the runtime object
105
+ * is a plain byte transform.
106
+ */
107
+ export const zstdEncoder = (): TransformStream<Uint8Array, Uint8Array> =>
108
+ new CompressionStream('zstd') as unknown as TransformStream<Uint8Array, Uint8Array>
109
+
110
+ const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
111
+ new ReadableStream({
112
+ start(c) {
113
+ c.enqueue(bytes)
114
+ c.close()
115
+ },
116
+ })
117
+
118
+ /**
119
+ * The decoded tar as a stream: one call for a small artifact that
120
+ * declares its size, a streamed decode otherwise — the stream setup is
121
+ * ~35 µs, which matters at a thousand one-file artifacts and nowhere
122
+ * else. Bytes in memory are only ever the small case (a large source
123
+ * must be a FILE: a Blob copies its bytes and hands the decoder
124
+ * everything at once, measured +519 MiB on 150 MiB against +448 for the
125
+ * plain decode). The 2 GiB ceiling applies either way: the declaration
126
+ * and the result length in one call, a running count on the stream.
127
+ */
128
+ export async function decodedTar(
129
+ source: Uint8Array | Bun.BunFile,
130
+ hash: string,
131
+ /**
132
+ * The decompression ceiling: `Cache` passes its `artifactCeiling`, whose
133
+ * default IS this module's. Reaching the real one needs an artifact that
134
+ * expands past 2 GiB, which no test can produce — so before item 487 the
135
+ * sizeless-bomb defense this module's comments promise had nothing
136
+ * asserting it, while the declared-size half was pinned by a forged
137
+ * header costing 20 bytes.
138
+ */
139
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
140
+ ): Promise<ReadableStream<Uint8Array>> {
141
+ if (source instanceof Uint8Array) {
142
+ if (assertDeclaredSize(source, hash, cap) === null) {
143
+ return zstdDecodeStream(new Blob([source]), hash, cap)
144
+ }
145
+ return oneChunk(await zstdDecompressBounded(source, hash, cap))
146
+ }
147
+ if (source.size <= STREAM_DECODE_FROM) {
148
+ const bytes = await source.bytes()
149
+ if (assertDeclaredSize(bytes, hash, cap) === null) return zstdDecodeStream(source, hash, cap)
150
+ return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
151
+ }
152
+ assertDeclaredSize(await source.slice(0, 32).bytes(), hash, cap)
153
+ return zstdDecodeStream(source, hash, cap)
154
+ }
155
+
156
+ /**
157
+ * The streaming twin of `zstdDecompressBounded`: the decoded bytes as a
158
+ * stream, refused past the same ceiling. A malicious frame cannot expand
159
+ * past the cap here either — the count runs as bytes are produced, before
160
+ * any of them reach the reader's next entry.
161
+ */
162
+ function zstdDecodeStream(
163
+ source: Blob,
164
+ hash: string,
165
+ cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
166
+ ): ReadableStream<Uint8Array> {
167
+ let total = 0
168
+ return source
169
+ .stream()
170
+ .pipeThrough(new DecompressionStream('zstd'))
171
+ .pipeThrough(
172
+ new TransformStream<Uint8Array, Uint8Array>({
173
+ transform(chunk, controller) {
174
+ total += chunk.byteLength
175
+ if (total > cap) {
176
+ controller.error(new CorruptArtifactError(hash, `decompresses past ${cap} bytes (cap)`))
177
+ return
178
+ }
179
+ controller.enqueue(chunk)
180
+ },
181
+ }),
182
+ )
183
+ }
package/src/cli/cache.ts CHANGED
@@ -1,11 +1,42 @@
1
- import { Cache } from '../cache/index.js'
2
- import { parseDecimalInt, parseSize } from '../util/index.js'
3
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
1
+ import { Cache, noteSchemaReset } from '../cache/index.js'
2
+ import { seeHelp } from './help.js'
3
+ import { nearest, parseDuration, parseSize } from '../util/index.js'
4
+ import { acquireRunLock } from '../orchestrator/index.js'
5
+ import { findWorkspaceRoot } from '../workspace/index.js'
6
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
4
7
  import { formatBytes } from './format.js'
5
8
 
6
9
  // parseSize moved to `util` (the orchestrator's resource resolver needs it
7
10
  // and can't import cli); re-exported here so existing callers are unchanged.
8
- export { parseSize } from '../util/index.js'
11
+ export { parseDuration, parseSize } from '../util/index.js'
12
+
13
+ /**
14
+ * `vx cache stats` is a habit from the other runners, and vx's answer is a
15
+ * different VERB rather than a missing one — saying so where the user typed
16
+ * it beats sending them to a help screen that lists only `prune` (walked the
17
+ * first-run path, 2026-09-20). A typo of `prune` itself gets the same
18
+ * nearest-neighbour hint a task, flag or project name gets.
19
+ */
20
+ const CACHE_ELSEWHERE: Record<string, string> = {
21
+ stats: '`vx info` reports the cache directory, its entry count and its size',
22
+ status: '`vx info` reports the cache directory, its entry count and its size',
23
+ size: '`vx info` reports the cache directory, its entry count and its size',
24
+ entries: '`vx info` reports the cache directory, its entry count and its size',
25
+ dir: '`vx info` prints the cache directory this workspace uses',
26
+ path: '`vx info` prints the cache directory this workspace uses',
27
+ clean: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
28
+ clear: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
29
+ rm: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
30
+ delete: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
31
+ evict: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
32
+ }
33
+
34
+ function cacheSubHint(sub: string): string {
35
+ const elsewhere = CACHE_ELSEWHERE[sub]
36
+ if (elsewhere !== undefined) return ` — ${elsewhere}`
37
+ const best = nearest(sub, ['prune'])
38
+ return best === undefined ? '' : `. Did you mean ${best}?`
39
+ }
9
40
 
10
41
  export async function cacheCmd(args: readonly string[]): Promise<number> {
11
42
  const [sub, ...rest] = args
@@ -16,7 +47,9 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
16
47
  process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
17
48
  return 1
18
49
  default:
19
- process.stderr.write(`vx cache: unknown subcommand: ${sub}\n`)
50
+ process.stderr.write(
51
+ `vx cache: unknown subcommand: ${sub}${cacheSubHint(sub)}${seeHelp('cache')}\n`,
52
+ )
20
53
  return 1
21
54
  }
22
55
  }
@@ -24,6 +57,9 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
24
57
  interface PruneArgs {
25
58
  olderThanMs?: number
26
59
  maxBytes?: number
60
+ dryRun?: boolean
61
+ /** `--cache-dir`: prune the cache a run with the same flag uses. */
62
+ cacheDir?: string
27
63
  error?: string
28
64
  }
29
65
 
@@ -60,9 +96,25 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
60
96
  error: `--max-size 0 would evict every entry — delete the cache directory instead`,
61
97
  }
62
98
  }
99
+ // A bare number is bytes to `parseSize` (a computed size needs that),
100
+ // but nobody caps a cache at 10 bytes: `--max-size 10`
101
+ // meant 10G or 10M and would evict everything, the same class of
102
+ // destructive typo the zero bound above refuses. An explicit `10B`
103
+ // still passes.
104
+ if (/^\d+$/.test(v)) {
105
+ return {
106
+ error: `--max-size ${v} would read as ${v} bytes and evict nearly everything — give a unit (e.g. ${v}M, ${v}G)`,
107
+ }
108
+ }
63
109
  out.maxBytes = bytes
110
+ } else if (a === '--dry-run') {
111
+ out.dryRun = true
64
112
  } else {
65
- return { error: `unknown argument: ${a}` }
113
+ const cd = parseCacheDirFlag(args, i)
114
+ if (cd === null) return { error: `unknown argument: ${a}${seeHelp('cache')}` }
115
+ if ('error' in cd) return { error: cd.error }
116
+ out.cacheDir = cd.cacheDir
117
+ i = cd.next
66
118
  }
67
119
  }
68
120
  if (out.olderThanMs === undefined && out.maxBytes === undefined) {
@@ -85,38 +137,44 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
85
137
  process.stderr.write(`vx cache prune: ${(err as Error).message}\n`)
86
138
  return 1
87
139
  }
88
- // Honor `defineWorkspace({ cacheDir: '...' })` — `vx run` and
89
- // `vx cache prune` must operate on the same directory or prune
90
- // silently no-ops against the wrong path.
91
- const workspaceConfig = await loadWorkspaceConfig(root)
92
- const cache = new Cache(resolveCacheDir(root, workspaceConfig))
140
+ // Honor `--cache-dir`, `defineWorkspace({ cacheDir: '...' })` and a
141
+ // `config` plugin's edit of it — `vx run` and `vx cache prune` must
142
+ // operate on the same directory or prune silently no-ops against the
143
+ // wrong path.
144
+ // A dry run only reads: it never resets the index (item 896).
145
+ const dir = await cliCacheDir(root, parsed.cacheDir)
146
+ const cache = parsed.dryRun === true ? Cache.inspect(dir) : new Cache(dir)
147
+ // A prune deletes rows and artifacts; a cache this user cannot write is
148
+ // refused up front with the directory named, as a run refuses it, rather
149
+ // than dying in the first DELETE with SQLite's "readonly database" and a
150
+ // stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
151
+ // run only reads, and reads a read-only cache fine.
152
+ if (!parsed.dryRun) cache.assertWritable()
153
+ noteSchemaReset(cache, warnToStderr)
154
+ // A prune beside a run on this workspace evicts what the run has just
155
+ // probed as hits, whose `accessed_at` bumps it has not flushed yet; the
156
+ // run then re-runs those tasks (upstream survey, nx#36688). So a prune
157
+ // takes the run's lock and waits its turn. A dry run deletes nothing.
158
+ const releaseRunLock = parsed.dryRun
159
+ ? async (): Promise<void> => {}
160
+ : await acquireRunLock(root, { log: warnToStderr })
93
161
  try {
94
- const opts: { olderThanMs?: number; maxBytes?: number } = {}
162
+ const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
95
163
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
96
164
  if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
165
+ if (parsed.dryRun) opts.dryRun = true
97
166
  const result = await cache.prune(opts)
167
+ const dry = parsed.dryRun === true
168
+ const orphans =
169
+ result.orphans > 0
170
+ ? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
171
+ : ''
98
172
  process.stdout.write(
99
- `Pruned ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)} freed)\n`,
173
+ `${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
100
174
  )
101
175
  } finally {
102
176
  cache.close()
177
+ await releaseRunLock()
103
178
  }
104
179
  return 0
105
180
  }
106
-
107
- /**
108
- * Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
109
- * has always been case-insensitive; this matched only lowercase, so `30D`
110
- * was rejected while `1GB` and `1gb` both worked).
111
- */
112
- export function parseDuration(input: string): number | null {
113
- const m = input.match(/^(\d+)([smhd])$/i)
114
- if (!m) return null
115
- const n = parseDecimalInt(m[1]!)
116
- if (n === null) return null
117
- // Lowercase before the switch: with the /i flag an uppercase `M` would
118
- // otherwise fall through to the days branch.
119
- const unit = m[2]!.toLowerCase()
120
- const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
121
- return n * mult
122
- }
@@ -0,0 +1,104 @@
1
+ // `vx completions <shell>` — a bash / zsh / fish script over the verb table
2
+ // and each verb's help cut, so the verbs and every flag of each complete
3
+ // from the one text `vx <verb> --help` prints. Plugin verbs of the
4
+ // workspace around the cwd are included at generation time; task and
5
+ // project names are not (a completion that evaluates configs on every
6
+ // Tab is the wrong price).
7
+
8
+ import { CORE_VERBS, seeHelp, verbHelpText } from './help.js'
9
+
10
+ export type CompletionShell = 'bash' | 'zsh' | 'fish'
11
+ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
12
+
13
+ /** Every `--flag` the verb's help cut names, plus `--help`; deduped, in order of appearance. */
14
+ export function verbFlags(verb: string): string[] {
15
+ const flags = new Set<string>(['--help'])
16
+ for (const f of verbHelpText(verb).match(/--[a-zA-Z][a-zA-Z-]*/g) ?? []) flags.add(f)
17
+ return [...flags]
18
+ }
19
+
20
+ /** The words that follow `vx <verb>` besides flags: subcommands and fixed choices. */
21
+ const VERB_WORDS: Readonly<Record<string, readonly string[]>> = {
22
+ cache: ['prune'],
23
+ completions: [...SHELLS],
24
+ }
25
+
26
+ export function completionScript(shell: CompletionShell, verbs: readonly string[]): string {
27
+ const table = verbs.map((v) => ({
28
+ verb: v,
29
+ // A plugin verb owns its own arguments; only `--help` is known here.
30
+ words: [
31
+ ...(VERB_WORDS[v] ?? []),
32
+ ...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
33
+ ],
34
+ }))
35
+ switch (shell) {
36
+ case 'bash':
37
+ return [
38
+ '# vx completions — generated by `vx completions bash`',
39
+ '_vx() {',
40
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
41
+ ' local verb="${COMP_WORDS[1]}"',
42
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
43
+ ` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
44
+ ' return',
45
+ ' fi',
46
+ ' case "$verb" in',
47
+ ...table.map(
48
+ (t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
49
+ ),
50
+ ' *) COMPREPLY=() ;;',
51
+ ' esac',
52
+ '}',
53
+ 'complete -F _vx vx',
54
+ '',
55
+ ].join('\n')
56
+ case 'zsh':
57
+ return [
58
+ '#compdef vx',
59
+ '# vx completions — generated by `vx completions zsh`',
60
+ '_vx() {',
61
+ ' if (( CURRENT == 2 )); then',
62
+ ` compadd -- ${verbs.join(' ')}`,
63
+ ' return',
64
+ ' fi',
65
+ ' case "${words[2]}" in',
66
+ ...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
67
+ ' esac',
68
+ '}',
69
+ 'compdef _vx vx',
70
+ '',
71
+ ].join('\n')
72
+ case 'fish':
73
+ return [
74
+ '# vx completions — generated by `vx completions fish`',
75
+ 'complete -c vx -f',
76
+ ...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
77
+ ...table.flatMap((t) =>
78
+ t.words.map((w) =>
79
+ w.startsWith('--')
80
+ ? `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -l ${w.slice(2)}`
81
+ : `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -a ${w}`,
82
+ ),
83
+ ),
84
+ '',
85
+ ].join('\n')
86
+ }
87
+ }
88
+
89
+ export async function completionsCmd(
90
+ args: readonly string[],
91
+ pluginVerbs: readonly string[],
92
+ ): Promise<number> {
93
+ const shell = args[0]
94
+ if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
95
+ process.stderr.write(
96
+ `vx completions: expected one shell — bash, zsh or fish${shell !== undefined && args.length === 1 ? ` (got ${shell})` : ''}${seeHelp('completions')}\n`,
97
+ )
98
+ return 1
99
+ }
100
+ const verbs = [...CORE_VERBS.filter((v) => v !== 'help' && v !== 'version'), 'help', 'version']
101
+ for (const v of pluginVerbs) if (!verbs.includes(v)) verbs.push(v)
102
+ process.stdout.write(completionScript(shell as CompletionShell, verbs))
103
+ return 0
104
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * One core per process. A plugin package, a `vx.workspace.ts`, a
3
+ * `vx.config.ts` all `import … from '@vzn/vx'`, and that specifier
4
+ * resolves through node_modules — to a SECOND copy of core when the host
5
+ * is the compiled binary (whose own copy lives under the bunfs root) or
6
+ * an install with its own nested `@vzn/vx`. The second copy is core's
7
+ * whole source transpiled again, per process: ~20 ms per plugin package
8
+ * in the binary (measured 2026-09-10, `@vzn/vx-otel`: 20–25 ms → 2–3 ms),
9
+ * and two module states — two `definePlugin` memos, two of everything
10
+ * keyed by identity rather than by registry symbol.
11
+ *
12
+ * The alias is a Bun virtual module for the exact specifier `@vzn/vx`,
13
+ * served from the host's own façade, loaded lazily on the first import
14
+ * so a verb that never loads a plugin never loads the façade. The
15
+ * source form (`bun src/bin.ts`) resolves the specifier to the same file
16
+ * anyway; the alias makes that a guarantee rather than a layout fact.
17
+ */
18
+ export function registerCoreAlias(load: () => Promise<Record<string, unknown>>): void {
19
+ Bun.plugin({
20
+ name: 'vx-core-alias',
21
+ setup(build) {
22
+ build.module('@vzn/vx', async () => ({ exports: await load(), loader: 'object' }))
23
+ },
24
+ })
25
+ }
package/src/cli/format.ts CHANGED
@@ -1,15 +1,4 @@
1
- /**
2
- * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
3
- * decimal of precision below 10 of any unit. Used by `vx stats` and
4
- * `vx cache prune` output.
5
- */
6
- export function formatBytes(n: number): string {
7
- if (n < 1024) return `${n} B`
8
- const units = ['KB', 'MB', 'GB', 'TB']
9
- let v = n / 1024
10
- for (const u of units) {
11
- if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
12
- v /= 1024
13
- }
14
- return `${v.toFixed(0)} PB`
15
- }
1
+ // `formatBytes` moved to util/size.ts beside the parsers (item 658): a run
2
+ // names what the workspace's `cacheRetention` evicted, and the orchestrator
3
+ // may not import the CLI.
4
+ export { formatBytes } from '../util/index.js'