@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,343 @@
1
+ // In-process tar reader (POSIX ustar) for cache-hit restore.
2
+ //
3
+ // We replace `Bun.spawn(['tar', '-xf', ...])` on the cache-hit path
4
+ // to save a per-restore fork+exec (~5-10ms on Linux). At 200 cached
5
+ // tasks per run, that's 1-2s of pure overhead reclaimed.
6
+ //
7
+ // Tar layout we accept (produced by GNU/BSD `tar -cf - -C stage X Y Z`):
8
+ // - 512-byte POSIX ustar headers
9
+ // - name : bytes 0..99 (null-padded)
10
+ // - mode : bytes 100..107 (octal ASCII, null-padded)
11
+ // - mtime : bytes 136..147 (octal ASCII seconds since epoch)
12
+ // - size : bytes 124..135 (octal ASCII)
13
+ // - typeflag : byte 156 ('0' or '\0' = file, '5' = dir,
14
+ // '2' = symlink, 'L' = GNU longname,
15
+ // 'x' / 'g' / 'X' = PAX extended
16
+ // header — metadata for the next
17
+ // entry, not a file itself)
18
+ // - data : `size` bytes, padded up to next 512-byte block
19
+ // - end : two 512-byte zero blocks
20
+ //
21
+ // We tolerate the GNU longname extension (typeflag 'L') because some
22
+ // tar binaries emit it for paths > 100 chars even when ustar's
23
+ // prefix+name (256 chars) would suffice. PAX extended-header records
24
+ // ('x' / 'g' / 'X') are SKIPPED — BSD tar (macOS default) emits one
25
+ // per entry for xattrs / mtime-nanos / SCHILY metadata. We don't
26
+ // need any of that; treating the headers as regular files would put
27
+ // `PaxHeaders/foo` junk entries into the restored tree.
28
+ //
29
+ // AppleDouble files (`._<name>`) are also SKIPPED — macOS Finder /
30
+ // `cp -p` leave these resource-fork siblings around (e.g. `._main.js`
31
+ // next to `main.js`). They're not real outputs; filtering at parse
32
+ // time prevents them from showing up in restored trees even when an
33
+ // older / contaminated cache entry includes them. The matching
34
+ // input-side filter lives in `src/cache/inputs.ts:ALWAYS_IGNORE`.
35
+ //
36
+ // We DO NOT support sparse files, character/block devices, or
37
+ // hardlinks — they don't appear in build outputs.
38
+
39
+ import { lstat, mkdir, chmod, realpath, unlink, utimes } from 'node:fs/promises'
40
+ import path from 'node:path'
41
+
42
+ export interface TarHeader {
43
+ /** Full archive-relative name, e.g. "outputs/dist/index.js" or "manifest.json". */
44
+ name: string
45
+ size: number
46
+ /** File permission bits, e.g. 0o644. */
47
+ mode: number
48
+ /** Modification time in ms since epoch (seconds-resolution from tar). */
49
+ mtimeMs: number
50
+ isDir: boolean
51
+ /** Offset in `tarBytes` where the file's data starts. */
52
+ dataOffset: number
53
+ }
54
+
55
+ /**
56
+ * Walk a tar archive's headers and return them in source order.
57
+ * Cheap: only reads 512-byte blocks; never decodes file bodies.
58
+ */
59
+ export function parseTarHeaders(tarBytes: Uint8Array): TarHeader[] {
60
+ const headers: TarHeader[] = []
61
+ const dec = new TextDecoder('utf-8')
62
+ let off = 0
63
+ let pendingLongName: string | null = null
64
+
65
+ while (off + 512 <= tarBytes.length) {
66
+ const header = tarBytes.subarray(off, off + 512)
67
+ // End-of-archive marker: 512 zero bytes (tar pads two such blocks).
68
+ let zero = true
69
+ for (let i = 0; i < 512 && zero; i++) {
70
+ if (header[i] !== 0) zero = false
71
+ }
72
+ if (zero) break
73
+
74
+ let nameEnd = 0
75
+ while (nameEnd < 100 && header[nameEnd] !== 0) nameEnd++
76
+ const rawName = dec.decode(header.subarray(0, nameEnd)).replace(/^\.\//, '')
77
+
78
+ const modeStr = dec.decode(header.subarray(100, 108)).trim().replace(/ +$/, '')
79
+ const mode = parseInt(modeStr, 8) || 0o644
80
+
81
+ const sizeStr = dec.decode(header.subarray(124, 136)).trim().replace(/ +$/, '')
82
+ const size = parseInt(sizeStr, 8) || 0
83
+
84
+ const mtimeStr = dec.decode(header.subarray(136, 148)).trim().replace(/ +$/, '')
85
+ const mtimeMs = (parseInt(mtimeStr, 8) || 0) * 1000
86
+
87
+ const typeFlag = header[156]
88
+ const isDir = typeFlag === 0x35 /* '5' */ || rawName.endsWith('/')
89
+
90
+ const dataStart = off + 512
91
+ const padded = Math.ceil(size / 512) * 512
92
+
93
+ if (typeFlag === 0x4c /* 'L' — GNU longname */) {
94
+ // The data block carries the next entry's full name (null-
95
+ // terminated). Stash it; the actual file header follows.
96
+ // Strip the null terminator(s) without writing a regex that lint
97
+ // flags as a control-char match.
98
+ const raw = dec.decode(tarBytes.subarray(dataStart, dataStart + size))
99
+ let endIdx = raw.length
100
+ while (endIdx > 0 && raw.charCodeAt(endIdx - 1) === 0) endIdx--
101
+ pendingLongName = raw.slice(0, endIdx)
102
+ off = dataStart + padded
103
+ continue
104
+ }
105
+
106
+ // PAX extended headers: metadata about the next entry (long
107
+ // names, large sizes, nanosecond mtime, BSD xattrs, etc). Their
108
+ // own entry name is something like `PaxHeaders/<basename>` or
109
+ // `./PaxHeader.NNNN/<basename>` — definitely not a file we want
110
+ // to write to disk. Skip the whole record (header + padded data)
111
+ // and let the next entry through unchanged.
112
+ if (
113
+ typeFlag === 0x78 /* 'x' — per-entry PAX */ ||
114
+ typeFlag === 0x67 /* 'g' — global PAX */ ||
115
+ typeFlag === 0x58 /* 'X' — Solaris extended */
116
+ ) {
117
+ off = dataStart + padded
118
+ continue
119
+ }
120
+
121
+ // Reject typeflags we don't support: hardlink (1), symlink (2),
122
+ // char device (3), block device (4), FIFO (6), contiguous (7).
123
+ // Build outputs are regular files + directories; anything else
124
+ // is either a producer bug or a smuggled non-file artifact (a
125
+ // symlink to /etc/passwd, a /dev/null mapping, etc.). Reject
126
+ // explicitly so future extractors can't be tricked into honoring
127
+ // these by accident.
128
+ if (typeFlag === 0x31 /* '1' */) {
129
+ throw new TarSecurityError(`tar entry typeflag 'hardlink' is unsupported: ${rawName}`)
130
+ }
131
+ if (typeFlag === 0x32 /* '2' */) {
132
+ throw new TarSecurityError(`tar entry typeflag 'symlink' is unsupported: ${rawName}`)
133
+ }
134
+ if (typeFlag === 0x33 /* '3' */) {
135
+ throw new TarSecurityError(`tar entry typeflag 'chardev' is unsupported: ${rawName}`)
136
+ }
137
+ if (typeFlag === 0x34 /* '4' */) {
138
+ throw new TarSecurityError(`tar entry typeflag 'blockdev' is unsupported: ${rawName}`)
139
+ }
140
+ if (typeFlag === 0x36 /* '6' */) {
141
+ throw new TarSecurityError(`tar entry typeflag 'fifo' is unsupported: ${rawName}`)
142
+ }
143
+ if (typeFlag === 0x37 /* '7' */) {
144
+ throw new TarSecurityError(`tar entry typeflag 'contiguous' is unsupported: ${rawName}`)
145
+ }
146
+
147
+ const name = pendingLongName ?? rawName
148
+ pendingLongName = null
149
+
150
+ if (name.length === 0) {
151
+ off = dataStart + padded
152
+ continue
153
+ }
154
+
155
+ // Path-traversal defense. We only ever want to extract entries
156
+ // under `outputs/<rel>` where `<rel>` is project-relative and
157
+ // contains no `..` segments and no absolute prefix. Reject at
158
+ // parse time so a malicious cache artifact can't write outside
159
+ // the destination (zip-slip class). Mirrors Turbo's
160
+ // restore.rs:check_path normalization.
161
+ if (name.startsWith('/')) {
162
+ throw new TarSecurityError(`tar entry name is absolute (unsafe): ${name}`)
163
+ }
164
+ if (hasParentSegment(name)) {
165
+ throw new TarSecurityError(`tar entry name escapes via '..' (unsafe): ${name}`)
166
+ }
167
+ if (name.includes('//')) {
168
+ throw new TarSecurityError(`tar entry name has empty path component (unsafe): ${name}`)
169
+ }
170
+ // Windows-shaped paths are categorically unsafe to extract on
171
+ // POSIX hosts (and vice versa): backslash separators, drive
172
+ // letters, extended-length prefixes. Reject before we touch
173
+ // anything.
174
+ if (name.includes('\\')) {
175
+ throw new TarSecurityError(`tar entry uses backslash separators (windows-unsafe): ${name}`)
176
+ }
177
+ if (/^[A-Za-z]:[\\/]/.test(name)) {
178
+ throw new TarSecurityError(`tar entry has windows drive prefix (unsafe): ${name}`)
179
+ }
180
+
181
+ // AppleDouble resource-fork siblings — basename starting with
182
+ // `._`. macOS Finder / `cp -p` leave these next to real files
183
+ // (e.g. `._main.js` shadowing `main.js`). They carry xattrs we
184
+ // don't care about; restoring them just pollutes the tree.
185
+ const basename = name.slice(name.lastIndexOf('/') + 1)
186
+ if (basename.startsWith('._')) {
187
+ off = dataStart + padded
188
+ continue
189
+ }
190
+
191
+ headers.push({ name, size, mode, mtimeMs, isDir, dataOffset: dataStart })
192
+ off = dataStart + padded
193
+ }
194
+ return headers
195
+ }
196
+
197
+ /**
198
+ * Thrown by `parseTarHeaders` when an entry's name would let it
199
+ * escape the extraction destination (absolute path, `..` traversal,
200
+ * doubled separators). Callers should treat this as a corrupt /
201
+ * malicious artifact — log it, drop the cache entry, and re-run.
202
+ */
203
+ export class TarSecurityError extends Error {
204
+ constructor(message: string) {
205
+ super(message)
206
+ this.name = 'TarSecurityError'
207
+ }
208
+ }
209
+
210
+ function hasParentSegment(p: string): boolean {
211
+ if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
212
+ return p.includes('/../')
213
+ }
214
+
215
+ /**
216
+ * Read a single tar entry's text body. Returns '' for missing entries.
217
+ * Used to extract `stdout` / `stderr` inline.
218
+ */
219
+ export function readTarText(tarBytes: Uint8Array, headers: TarHeader[], name: string): string {
220
+ const h = headers.find((e) => e.name === name)
221
+ if (!h) return ''
222
+ return new TextDecoder('utf-8').decode(tarBytes.subarray(h.dataOffset, h.dataOffset + h.size))
223
+ }
224
+
225
+ /**
226
+ * Extract a tar's `outputs/<rel>` entries into `destDir/<rel>` (strips
227
+ * the `outputs/` prefix), and — when `workspaceDest` is given — its
228
+ * `workspace-outputs/<rel>` entries into `workspaceDest/<rel>`.
229
+ * Entries in neither namespace are ignored; workspace entries are
230
+ * also ignored when no `workspaceDest` is supplied.
231
+ */
232
+ export async function extractOutputs(
233
+ tarBytes: Uint8Array,
234
+ destDir: string,
235
+ workspaceDest?: string,
236
+ ): Promise<void> {
237
+ const headers = parseTarHeaders(tarBytes)
238
+
239
+ await mkdir(destDir, { recursive: true })
240
+
241
+ // Each namespace anchors at its own destination dir.
242
+ const destFor = (name: string): { base: string; rel: string } | null => {
243
+ if (name.startsWith('outputs/')) {
244
+ return { base: destDir, rel: name.slice('outputs/'.length) }
245
+ }
246
+ if (workspaceDest !== undefined && name.startsWith('workspace-outputs/')) {
247
+ return { base: workspaceDest, rel: name.slice('workspace-outputs/'.length) }
248
+ }
249
+ return null
250
+ }
251
+
252
+ // Two passes: directories first (depth-first via sort), then files.
253
+ // Keeps file writes from racing with their parent dir creation.
254
+ const dirEntries = headers
255
+ .filter((h) => h.isDir)
256
+ .map((h) => destFor(h.name))
257
+ .filter((d): d is { base: string; rel: string } => d !== null && d.rel.length > 0)
258
+ .sort((a, b) => (a.rel < b.rel ? -1 : 1))
259
+ for (const d of dirEntries) {
260
+ await mkdir(path.join(d.base, d.rel), { recursive: true })
261
+ }
262
+
263
+ const fileEntries = headers
264
+ .map((h) => ({ h, dest: destFor(h.name) }))
265
+ .filter(
266
+ (e): e is { h: TarHeader; dest: { base: string; rel: string } } =>
267
+ e.dest !== null && !e.h.isDir && e.dest.rel.length > 0,
268
+ )
269
+
270
+ // The REAL path of each base dir (symlinks resolved), memoized. Used to
271
+ // catch a symlinked ANCESTOR under the output tree: the lexical check
272
+ // below stops a `..` name, but if `<dest>/dist` is already an on-disk
273
+ // symlink pointing outside (planted in the repo or by a dependency
274
+ // postinstall), a lexically-contained entry `dist/x` would follow it and
275
+ // escape. Both sides are realpath'd, so a legitimate symlinked ANCESTOR of
276
+ // the base dir itself (e.g. macOS /tmp → /private/tmp) resolves
277
+ // consistently and is not flagged.
278
+ const realBaseCache = new Map<string, string>()
279
+ const realBaseOf = async (base: string): Promise<string> => {
280
+ const key = path.resolve(base)
281
+ let r = realBaseCache.get(key)
282
+ if (r === undefined) {
283
+ r = await realpath(key).catch(() => key)
284
+ realBaseCache.set(key, r)
285
+ }
286
+ return r
287
+ }
288
+
289
+ await Promise.all(
290
+ fileEntries.map(async ({ h, dest }) => {
291
+ const destResolved = path.resolve(dest.base)
292
+ const target = path.join(dest.base, dest.rel)
293
+ // Defense in depth — parseTarHeaders already rejects `..` /
294
+ // absolute paths, but if a future glitch lets one through we
295
+ // catch it here. path.resolve normalizes `..` components in
296
+ // the joined path so the comparison is sound.
297
+ const targetResolved = path.resolve(target)
298
+ if (targetResolved !== destResolved && !targetResolved.startsWith(destResolved + path.sep)) {
299
+ throw new TarSecurityError(`tar entry escapes destDir (unsafe): ${h.name}`)
300
+ }
301
+
302
+ // Ensure the parent dir exists. Cheap because mkdir(recursive)
303
+ // is a no-op when the dir is already there.
304
+ await mkdir(path.dirname(target), { recursive: true })
305
+
306
+ // Symlinked-parent escape check: the parent dir now exists — resolve
307
+ // its real path and require it to still sit inside the real base dir.
308
+ // A poisoned artifact whose entry lands under a pre-existing symlinked
309
+ // directory is refused before any bytes are written outside the tree.
310
+ const realBase = await realBaseOf(dest.base)
311
+ const realParent = await realpath(path.dirname(target)).catch(() => path.dirname(target))
312
+ if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) {
313
+ throw new TarSecurityError(`tar entry escapes destDir via a symlinked parent: ${h.name}`)
314
+ }
315
+
316
+ // Symlink TOCTOU defense: if the target IS a symlink, unlink
317
+ // it first so the upcoming write doesn't follow the link and
318
+ // clobber whatever the link points to (e.g. an attacker placed
319
+ // `<dest>/link -> /etc/passwd` to redirect our write). lstat
320
+ // doesn't follow the symlink, so we can detect the link itself.
321
+ try {
322
+ const ls = await lstat(target)
323
+ if (ls.isSymbolicLink()) await unlink(target)
324
+ } catch {
325
+ // Target doesn't exist — that's the common case; fall through.
326
+ }
327
+
328
+ const body = tarBytes.subarray(h.dataOffset, h.dataOffset + h.size)
329
+ // Bun.write benchmarks ~2× faster than fs/promises.writeFile
330
+ // for these per-entry restore writes (167µs vs 361µs per 1KB
331
+ // on the dev box; ~1.6× in batched Promise.all over 50 files).
332
+ // Native fast path; same correctness contract.
333
+ await Bun.write(target, body)
334
+ // Permission bits + mtime restoration — keeps a re-tar of the
335
+ // restored tree byte-identical to the original artifact.
336
+ if ((h.mode & 0o777) !== 0) await chmod(target, h.mode & 0o777)
337
+ if (h.mtimeMs > 0) {
338
+ const t = h.mtimeMs / 1000
339
+ await utimes(target, t, t)
340
+ }
341
+ }),
342
+ )
343
+ }
@@ -0,0 +1,23 @@
1
+ // Execution as a pluggable backend — the same idea as the cache's
2
+ // local/remote split, applied to running tasks. `vx run` resolves a
3
+ // backend and calls `run(request)`; it neither knows nor cares whether the
4
+ // work happened in-process or was delegated to a service. The DEFAULT is
5
+ // pure in-process; routing to a service (local or hosted) is a `backend`
6
+ // plugin capability, never core.
7
+
8
+ import {
9
+ run as runOrchestrator,
10
+ projectOutcome,
11
+ requestToOptions,
12
+ type RunBackend,
13
+ } from '../orchestrator/index.js'
14
+
15
+ /** Run in-process via `run()` — core's default backend. Byte-identical to a plain `run()`. */
16
+ export function localBackend(): RunBackend {
17
+ return {
18
+ async run(request) {
19
+ const summary = await runOrchestrator(requestToOptions(request))
20
+ return { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
21
+ },
22
+ }
23
+ }
@@ -0,0 +1,96 @@
1
+ import { Cache } from '../cache/index.js'
2
+ import { parseSize } from '../util/index.js'
3
+ import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
4
+ import { formatBytes } from './format.js'
5
+
6
+ // parseSize moved to `util` (the orchestrator's resource resolver needs it
7
+ // and can't import cli); re-exported here so existing callers are unchanged.
8
+ export { parseSize } from '../util/index.js'
9
+
10
+ export async function cacheCmd(args: readonly string[]): Promise<number> {
11
+ const [sub, ...rest] = args
12
+ switch (sub) {
13
+ case 'prune':
14
+ return await pruneCmd(rest)
15
+ case undefined:
16
+ process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
17
+ return 1
18
+ default:
19
+ process.stderr.write(`vx cache: unknown subcommand: ${sub}\n`)
20
+ return 1
21
+ }
22
+ }
23
+
24
+ interface PruneArgs {
25
+ olderThanMs?: number
26
+ maxBytes?: number
27
+ error?: string
28
+ }
29
+
30
+ export function parsePruneArgs(args: readonly string[]): PruneArgs {
31
+ const out: PruneArgs = {}
32
+ for (let i = 0; i < args.length; i++) {
33
+ const a = args[i]
34
+ if (a === '--older-than') {
35
+ const v = args[++i]
36
+ if (v === undefined) return { error: `${a} requires a value (e.g. 30d, 24h, 60m)` }
37
+ const ms = parseDuration(v)
38
+ if (ms === null) return { error: `invalid duration: ${v}` }
39
+ out.olderThanMs = Date.now() - ms
40
+ } else if (a === '--max-size') {
41
+ const v = args[++i]
42
+ if (v === undefined) return { error: `${a} requires a value (e.g. 500M, 1G)` }
43
+ const bytes = parseSize(v)
44
+ if (bytes === null) return { error: `invalid size: ${v}` }
45
+ out.maxBytes = bytes
46
+ } else {
47
+ return { error: `unknown argument: ${a}` }
48
+ }
49
+ }
50
+ if (out.olderThanMs === undefined && out.maxBytes === undefined) {
51
+ return { error: 'must pass --older-than <duration> or --max-size <bytes>' }
52
+ }
53
+ return out
54
+ }
55
+
56
+ async function pruneCmd(args: readonly string[]): Promise<number> {
57
+ const parsed = parsePruneArgs(args)
58
+ if (parsed.error) {
59
+ process.stderr.write(`vx cache prune: ${parsed.error}\n`)
60
+ return 1
61
+ }
62
+ const cwd = process.cwd()
63
+ let root: string
64
+ try {
65
+ root = await findWorkspaceRoot(cwd)
66
+ } catch (err) {
67
+ process.stderr.write(`vx cache prune: ${(err as Error).message}\n`)
68
+ return 1
69
+ }
70
+ // Honor `defineWorkspace({ cacheDir: '...' })` — `vx run` and
71
+ // `vx cache prune` must operate on the same directory or prune
72
+ // silently no-ops against the wrong path.
73
+ const workspaceConfig = await loadWorkspaceConfig(root)
74
+ const cache = new Cache(resolveCacheDir(root, workspaceConfig))
75
+ try {
76
+ const opts: { olderThanMs?: number; maxBytes?: number } = {}
77
+ if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
78
+ if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
79
+ const result = await cache.prune(opts)
80
+ process.stdout.write(
81
+ `Pruned ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)} freed)\n`,
82
+ )
83
+ } finally {
84
+ cache.close()
85
+ }
86
+ return 0
87
+ }
88
+
89
+ export function parseDuration(input: string): number | null {
90
+ const m = input.match(/^(\d+)([smhd])$/)
91
+ if (!m) return null
92
+ const n = Number(m[1])
93
+ const unit = m[2]
94
+ const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
95
+ return n * mult
96
+ }
@@ -0,0 +1,15 @@
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
+ }
@@ -0,0 +1,129 @@
1
+ export function printHelp(): void {
2
+ process.stdout.write(
3
+ [
4
+ 'vx — open, extensible monorepo task runner',
5
+ '',
6
+ 'Usage:',
7
+ ' vx run [OPTIONS] [TASK | PKG#TASK] [-- forwarded-args...]',
8
+ ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
9
+ ' vx cache prune [--older-than <duration>] [--max-size <bytes>]',
10
+ ' vx lock [--check]',
11
+ ' vx migrate [--from turbo|nx] [--dry] [--force]',
12
+ ' vx upgrade [tag]',
13
+ ' vx show [PROJECT[#TASK]] [--format pretty|json]',
14
+ ' vx info',
15
+ ' vx mcp [--stdio]',
16
+ ' vx help',
17
+ ' vx version',
18
+ '',
19
+ 'Selection (for run):',
20
+ ' (default) Run task in the project containing cwd.',
21
+ ' --all Run task in every project that declares it.',
22
+ ' --filter <pat> pnpm-style filter (repeatable). Examples:',
23
+ ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
24
+ ' foo^..., !foo, [<since>]',
25
+ ' --affected[=<base>] Sugar for --filter "[<base>]". Default base =',
26
+ ' origin/HEAD (falls back to HEAD~1).',
27
+ " pkg#task Run a specific project's task directly.",
28
+ '',
29
+ 'Execution (for run):',
30
+ ' --concurrency <n> Max parallel tasks (default: CPU count).',
31
+ ' --excludeDependencies[=names] Skip dependsOn edges. No value = all; comma list = specific names.',
32
+ ' --no-cache Disable caching entirely (no reads, no writes, outputs left alone).',
33
+ ' --force Re-execute everything (skip reads) but still refresh the cache (writes on).',
34
+ ' --cache <spec> Per-layer read/write control. Comma list of <layer>:<flags>',
35
+ ' (layer = local|remote, flags ⊆ rw). A named layer is set EXACTLY',
36
+ ' to its flags; unnamed layers keep their value. Examples:',
37
+ ' local:rw,remote:r (remote read-only), remote: (remote off),',
38
+ ' local:r (local read-only).',
39
+ ' --cache-dir <path> Cache directory override (cwd-relative). Beats the workspace',
40
+ ' cacheDir field + the .vx/cache default.',
41
+ ' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
42
+ " without their own exec.retries; explicit config wins). Doesn't",
43
+ ' affect cache keys.',
44
+ ' --timeout <ms> Default per-task timeout for tasks without their own exec.timeout',
45
+ ' (above VX_TASK_TIMEOUT env + workspace timeout). Kills + fails',
46
+ ' a runaway task. Per-task exec.timeout always wins.',
47
+ ' --memory <size> Memory budget (e.g. 8GB) that exec.resources.memory reservations',
48
+ ' pack against. Default: total system RAM — pass this in',
49
+ ' cgroup-limited containers (the default reads the HOST RAM).',
50
+ ' Reservations are per-task config (exec.resources), not flags.',
51
+ ' --verify[=<what>] Prove cache correctness. determinism (default): re-run each',
52
+ ' executed cacheable task + content-compare outputs. inputs: sandbox',
53
+ ' with the declared-input baseline + flag reads of undeclared inputs.',
54
+ ' fingerprint: ship output-tree fingerprints for the cross-machine',
55
+ ' diff on a connected serve (~1× exec — no re-run). all: everything.',
56
+ ' An unsafe task fails the run with the exact paths.',
57
+ ' ~2× exec (determinism); CI-shaped. inputs/all need the OS sandbox.',
58
+ ' --verify-allow <pkg#task,…> Exempt known-nondeterministic tasks from failing --verify.',
59
+ ' --frozen Load configs from vx-lock.json (CI; pair with vx lock --check).',
60
+ ' --output-logs <mode> full | errors-only | none — overrides the flow default',
61
+ ' (focused without --all/--filter/--affected, broad with, full in CI).',
62
+ ' --verbosity <n> 0=quiet, 1=verbose summary, 2+=debug (reserved).',
63
+ '',
64
+ 'Planning (for run — skips execution):',
65
+ ' --dry[=text|json] Print the task graph + cache hit/miss prediction.',
66
+ ' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
67
+ '',
68
+ 'Artifacts (for run):',
69
+ ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
70
+ ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
71
+ '',
72
+ 'Extensions (plugins):',
73
+ ' vx core runs tasks in-process and nothing else. A dashboard, remote',
74
+ ' cache, distributed execution, and telemetry export are provided by',
75
+ ' PLUGINS declared in vx.workspace.ts — first-party or your own. Core',
76
+ ' depends on none of them; a plain `vx run` never requires a plugin.',
77
+ ' See the plugin guide: https://vznjs.github.io/vx/guides/plugins/',
78
+ '',
79
+ 'Argument forwarding (for run):',
80
+ " Anything after `--` is forwarded (shell-quoted) to the task's exec",
81
+ ' command. The forwarded args are folded into the cache key.',
82
+ '',
83
+ 'Watch mode:',
84
+ ' vx watch <task> Initial run, then re-run on filesystem changes.',
85
+ ' Uses the same flags as `vx run` except --dry /',
86
+ ' --graph / --summarize / --profile. Press Ctrl+C',
87
+ ' to stop. Cache means most re-runs are near-zero',
88
+ ' cost (cache hits restore outputs + replay logs).',
89
+ '',
90
+ 'Cache management:',
91
+ ' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
92
+ ' vx cache prune --max-size 1G Keep total cache under 1 GB (LRU eviction).',
93
+ '',
94
+ ' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
95
+ '',
96
+ 'Introspection:',
97
+ ' vx show List every project: name, dir, declared task count.',
98
+ " vx show <project> Print the project's LIVE resolved config (fresh",
99
+ ' evaluation, not the lock), one block per task.',
100
+ " vx show <pkg>#<task> Print a single task's resolved config.",
101
+ ' --format <fmt> pretty (default) | json.',
102
+ ' vx info Workspace doctor: vx/bun/git versions, project +',
103
+ ' task counts, cache dir/entries/size, recent runs,',
104
+ ' lock status.',
105
+ ' vx stats Deprecated: alias of vx info.',
106
+ ' vx mcp Model Context Protocol server (stdio) — exposes inspector',
107
+ ' RPCs as MCP tools for AI agents (Claude Code, Cursor, …).',
108
+ '',
109
+ 'Migration:',
110
+ ' vx migrate Generate vx.config.ts per package from turbo.json or an',
111
+ ' Nx project graph (.nx/workspace-data/project-graph.json).',
112
+ ' Unmappable settings become TODO(vx-migrate) comments;',
113
+ ' turbo global fields become a shared vx-preset.ts.',
114
+ ' --dry Print the generated files instead of writing them.',
115
+ ' --force Overwrite existing vx.config.* / vx-preset.ts files.',
116
+ '',
117
+ 'Config lock:',
118
+ ' vx lock Evaluate every vx.config in the current environment and',
119
+ ' freeze the resolved objects into vx-lock.json. Plain runs',
120
+ ' always evaluate live; only `vx run --frozen` consumes the',
121
+ ' lock (CI reproducibility, frozen-env semantics).',
122
+ ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
123
+ ' re-evaluation compared against the frozen objects. Exits 1',
124
+ ' on drift (e.g. configs that read env vars). Run it before',
125
+ ' `vx run --frozen` in CI.',
126
+ '',
127
+ ].join('\n'),
128
+ )
129
+ }