@vzn/vx 0.0.631 → 0.0.632

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 (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. package/src/workspace/workspace.ts +1 -0
@@ -187,30 +187,45 @@ class Source {
187
187
  }
188
188
  }
189
189
 
190
+ /** What one header block means, decided by `TarDecoder.header`. */
191
+ type HeaderStep =
192
+ /** A zero block: the first half of the end marker. */
193
+ | { kind: 'zero' }
194
+ /** The second zero block in a row: the archive is over. */
195
+ | { kind: 'end' }
196
+ /** An extended header (`x`, `L`) or a global one (`g`); its padded body follows. */
197
+ | { kind: 'extended'; type: 'x' | 'L' | 'g'; size: number; padded: number }
198
+ /** An entry; its padded body follows. */
199
+ | { kind: 'entry'; name: string; size: number; type: string; mtimeMs: number; padded: number }
200
+
201
+ const ZERO: HeaderStep = { kind: 'zero' }
202
+ const END: HeaderStep = { kind: 'end' }
203
+
190
204
  /**
191
- * Iterate a tar stream entry by entry. Each entry's `body` MUST be fully
192
- * consumed (or the iterator drains it) before the next entry is read.
205
+ * The dialect, decoded off whole blocks with no I/O: the one copy of the
206
+ * header rules both readers run — `tarEntries` over a stream (restore,
207
+ * ingest) and `tarEntriesSync` over a tar in memory (a save's own) — so
208
+ * a name a save indexes is the name a restore reads.
193
209
  */
194
- export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGenerator<TarEntry> {
195
- const src = new Source(stream[Symbol.asyncIterator]())
196
- let pendingPath: string | undefined
197
- let pendingSize: number | undefined
198
- let zeroBlocks = 0
199
- for (;;) {
200
- const h = await src.exact(BLOCK, 'a header')
201
- if (h === null) {
202
- if (zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
203
- return
204
- }
210
+ class TarDecoder {
211
+ private pendingPath: string | undefined
212
+ private pendingSize: number | undefined
213
+ private zeroBlocks = 0
214
+
215
+ /** The bytes ended at a header boundary: clean only after a zero block. */
216
+ eof(): void {
217
+ if (this.zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
218
+ }
219
+
220
+ header(h: Uint8Array): HeaderStep {
205
221
  if (isZeroBlock(h)) {
206
- zeroBlocks++
207
- if (zeroBlocks >= 2) return
208
- continue
222
+ this.zeroBlocks++
223
+ return this.zeroBlocks >= 2 ? END : ZERO
209
224
  }
210
- zeroBlocks = 0
225
+ this.zeroBlocks = 0
211
226
  if (!checksumOk(h)) throw new TarFormatError('header checksum mismatch')
212
227
  const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]!)
213
- const size = pendingSize ?? octal(h, 124, 12)
228
+ const size = this.pendingSize ?? octal(h, 124, 12)
214
229
  const mtimeMs = octal(h, 136, 12) * 1000
215
230
  const padded = Math.ceil(size / BLOCK) * BLOCK
216
231
  // POSIX `ustar\0` only: old GNU's `ustar ` keeps atime and ctime at
@@ -218,44 +233,67 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
218
233
  // `<atime>…/<name>`.
219
234
  const prefix = field(h, 257, 6) === 'ustar' ? field(h, 345, 155) : ''
220
235
  const rawName = field(h, 0, 100)
221
- let name = pendingPath ?? (prefix ? `${prefix}/${rawName}` : rawName)
222
- pendingPath = undefined
223
- pendingSize = undefined
224
-
225
- if ((type === 'x' || type === 'L' || type === 'g') && size > MAX_EXTENDED_HEADER) {
226
- throw new TarFormatError(
227
- `extended header of ${size} bytes, past ${MAX_EXTENDED_HEADER} (a hostile archive?)`,
228
- )
236
+ const name = this.pendingPath ?? (prefix ? `${prefix}/${rawName}` : rawName)
237
+ this.pendingPath = undefined
238
+ this.pendingSize = undefined
239
+ if (type === 'x' || type === 'L' || type === 'g') {
240
+ if (size > MAX_EXTENDED_HEADER) {
241
+ throw new TarFormatError(
242
+ `extended header of ${size} bytes, past ${MAX_EXTENDED_HEADER} (a hostile archive?)`,
243
+ )
244
+ }
245
+ return { kind: 'extended', type, size, padded }
229
246
  }
230
- if (type === 'x' || type === 'L') {
231
- // Extended header: applies to the NEXT entry only.
232
- const body = await src.exact(padded, 'an extended header')
233
- if (body === null) throw new TarFormatError('archive ends inside an extended header')
234
- if (type === 'x') {
235
- const pax = parsePax(body.subarray(0, size))
236
- const p = pax.get('path')
237
- if (p !== undefined) pendingPath = p
238
- const s = pax.get('size')
239
- if (s !== undefined) {
240
- // Digits only: `Number` reads `0.5` and `-1`, and a fractional
241
- // size moved the reader to a fractional offset.
242
- if (!/^[0-9]+$/.test(s) || !Number.isSafeInteger(Number(s))) {
243
- throw new TarFormatError(`bad pax size: ${JSON.stringify(s)}`)
244
- }
245
- pendingSize = Number(s)
246
- }
247
- } else {
248
- pendingPath = field(body, 0, size)
247
+ const trimmed = name.replace(/\/+$/, type === '5' ? '/' : '')
248
+ return { kind: 'entry', name: trimmed, size, type, mtimeMs, padded }
249
+ }
250
+
251
+ /** An `x` or `L` header's padded body: it applies to the NEXT entry only. */
252
+ extended(type: 'x' | 'L', body: Uint8Array, size: number): void {
253
+ if (type === 'L') {
254
+ this.pendingPath = field(body, 0, size)
255
+ return
256
+ }
257
+ const pax = parsePax(body.subarray(0, size))
258
+ const p = pax.get('path')
259
+ if (p !== undefined) this.pendingPath = p
260
+ const s = pax.get('size')
261
+ if (s !== undefined) {
262
+ // Digits only: `Number` reads `0.5` and `-1`, and a fractional
263
+ // size moved the reader to a fractional offset.
264
+ if (!/^[0-9]+$/.test(s) || !Number.isSafeInteger(Number(s))) {
265
+ throw new TarFormatError(`bad pax size: ${JSON.stringify(s)}`)
249
266
  }
250
- continue
267
+ this.pendingSize = Number(s)
251
268
  }
252
- if (type === 'g') {
253
- // pax global header: not used by Bun.Archive; skip its body.
254
- await src.exact(padded, 'a global header')
269
+ }
270
+ }
271
+
272
+ const extendedWhat = (type: 'x' | 'L' | 'g'): string =>
273
+ type === 'g' ? 'a global header' : 'an extended header'
274
+
275
+ /**
276
+ * Iterate a tar stream entry by entry. Each entry's `body` MUST be fully
277
+ * consumed (or the iterator drains it) before the next entry is read.
278
+ */
279
+ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGenerator<TarEntry> {
280
+ const src = new Source(stream[Symbol.asyncIterator]())
281
+ const tar = new TarDecoder()
282
+ for (;;) {
283
+ const h = await src.exact(BLOCK, 'a header')
284
+ if (h === null) return tar.eof()
285
+ const step = tar.header(h)
286
+ if (step.kind === 'end') return
287
+ if (step.kind === 'zero') continue
288
+ if (step.kind === 'extended') {
289
+ const body = await src.exact(step.padded, extendedWhat(step.type))
290
+ // pax global header: not used by Bun.Archive; its body is skipped.
291
+ if (step.type === 'g') continue
292
+ if (body === null) throw new TarFormatError('archive ends inside an extended header')
293
+ tar.extended(step.type, body, step.size)
255
294
  continue
256
295
  }
257
- name = name.replace(/\/+$/, type === '5' ? '/' : '')
258
-
296
+ const { name, size, padded } = step
259
297
  let drained = false
260
298
  const body = (async function* (): AsyncIterable<Uint8Array> {
261
299
  if (size > 0) for await (const c of src.take(size, `entry ${name}`)) yield c
@@ -265,7 +303,7 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
265
303
  }
266
304
  drained = true
267
305
  })()
268
- yield { name, size, type, mtimeMs, body }
306
+ yield { name, size, type: step.type, mtimeMs: step.mtimeMs, body }
269
307
  if (!drained) {
270
308
  // The caller skipped this body: drain it so the next header lines up.
271
309
  for await (const _ of body) {
@@ -275,6 +313,77 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
275
313
  }
276
314
  }
277
315
 
316
+ /** One entry of a tar in memory. `body()` is a view, checked against the tar's end when asked for. */
317
+ interface TarBytesEntry {
318
+ name: string
319
+ size: number
320
+ type: string
321
+ mtimeMs: number
322
+ body(): Uint8Array
323
+ }
324
+
325
+ /**
326
+ * `tarEntries` over a tar already in memory, synchronously, through the
327
+ * same decoder. A body is checked against the tar's end when it is read,
328
+ * as the stream reader checks it when it is drained, so the two refuse
329
+ * a damaged archive with the same class at the same point.
330
+ */
331
+ export function* tarEntriesSync(tar: Uint8Array): Generator<TarBytesEntry> {
332
+ const dec = new TarDecoder()
333
+ const len = tar.byteLength
334
+ let off = 0
335
+ for (;;) {
336
+ if (off === len) return dec.eof()
337
+ if (len - off < BLOCK) {
338
+ throw new TarFormatError(`archive ends inside a header (${len - off} of ${BLOCK} bytes)`)
339
+ }
340
+ const step = dec.header(tar.subarray(off, off + BLOCK))
341
+ off += BLOCK
342
+ if (step.kind === 'end') return
343
+ if (step.kind === 'zero') continue
344
+ if (step.kind === 'extended') {
345
+ const left = len - off
346
+ if (left < step.padded) {
347
+ if (left > 0) {
348
+ throw new TarFormatError(
349
+ `archive ends inside ${extendedWhat(step.type)} (${left} of ${step.padded} bytes)`,
350
+ )
351
+ }
352
+ // At the very end a global header's body is skipped as absent, and
353
+ // the end marker it lacks is what refuses the archive.
354
+ if (step.type === 'g') continue
355
+ throw new TarFormatError('archive ends inside an extended header')
356
+ }
357
+ if (step.type !== 'g')
358
+ dec.extended(step.type, tar.subarray(off, off + step.padded), step.size)
359
+ off += step.padded
360
+ continue
361
+ }
362
+ const { name, size, padded } = step
363
+ const at = off
364
+ // The stream reader's messages, word for word: the parity rows compare them.
365
+ const body = (): Uint8Array => {
366
+ const left = len - at
367
+ if (left < size) {
368
+ throw new TarFormatError(`archive ends inside entry ${name} (${left} of ${size} bytes)`)
369
+ }
370
+ if (left < padded) {
371
+ const pad = left - size
372
+ throw new TarFormatError(
373
+ pad === 0
374
+ ? `archive ends inside padding after ${name}`
375
+ : `archive ends inside padding after ${name} (${pad} of ${padded - size} bytes)`,
376
+ )
377
+ }
378
+ return tar.subarray(at, at + size)
379
+ }
380
+ yield { name, size, type: step.type, mtimeMs: step.mtimeMs, body }
381
+ // A body the caller skipped is still checked, as the stream drains it.
382
+ body()
383
+ off += padded
384
+ }
385
+ }
386
+
278
387
  // ─── Writer ───────────────────────────────────────────────────────────
279
388
 
280
389
  /** One regular file to pack. `body` is read as it is written. */
@@ -379,6 +488,48 @@ export function tarSize(inputs: readonly TarInput[]): number {
379
488
  return size
380
489
  }
381
490
 
491
+ /** An entry's blocks before its body: a pax `path` record when needed, then the ustar header. */
492
+ function entryHead(input: TarInput): Uint8Array[] {
493
+ const mode = input.mode ?? 0o644
494
+ const mtime = input.mtime ?? 0
495
+ if (!needsPax(input.name)) return [header(input.name, input.size, '0', mode, mtime)]
496
+ // Under a pax record the ustar name is a courtesy for readers that
497
+ // ignore pax: its first 100 BYTES, never characters — a multibyte
498
+ // name sliced by characters can be over 100 bytes again.
499
+ const pax = paxRecord('path', input.name)
500
+ return [
501
+ header('PaxHeaders/entry', pax.byteLength, 'x', 0o644, mtime),
502
+ pax,
503
+ padding(pax.byteLength),
504
+ header(encoder.encode(input.name).subarray(0, 100), input.size, '0', mode, mtime),
505
+ ]
506
+ }
507
+
508
+ /**
509
+ * `tarPack` of in-memory bodies, written into one buffer on this thread:
510
+ * the same bytes, without a generator hop per block (the small-artifact
511
+ * save, `packArtifactBytes`). Returns the bytes written.
512
+ */
513
+ export function tarPackInto(
514
+ inputs: readonly (TarInput & { body: Uint8Array | string })[],
515
+ out: Uint8Array,
516
+ ): number {
517
+ let off = 0
518
+ for (const input of inputs) {
519
+ for (const block of entryHead(input)) {
520
+ out.set(block, off)
521
+ off += block.byteLength
522
+ }
523
+ const bytes = typeof input.body === 'string' ? encoder.encode(input.body) : input.body
524
+ if (bytes.byteLength !== input.size)
525
+ throw new TarFormatError(`${input.name}: ${bytes.byteLength} bytes, ${input.size} declared`)
526
+ out.set(bytes, off)
527
+ // `out` is zeroed: padding and the end marker are a skip.
528
+ off += padded(input.size)
529
+ }
530
+ return off + BLOCK * 2
531
+ }
532
+
382
533
  /**
383
534
  * Pack regular files as a tar stream: ustar with the name/prefix split,
384
535
  * a pax `path` record when a name fits neither field — exactly the
@@ -389,20 +540,7 @@ export async function* tarPack(
389
540
  inputs: AsyncIterable<TarInput> | Iterable<TarInput>,
390
541
  ): AsyncGenerator<Uint8Array> {
391
542
  for await (const input of inputs) {
392
- const mode = input.mode ?? 0o644
393
- const mtime = input.mtime ?? 0
394
- // Under a pax record the ustar name is a courtesy for readers that
395
- // ignore pax: its first 100 BYTES, never characters — a multibyte
396
- // name sliced by characters can be over 100 bytes again.
397
- let headerName: string | Uint8Array = input.name
398
- if (needsPax(input.name)) {
399
- const pax = paxRecord('path', input.name)
400
- yield header('PaxHeaders/entry', pax.byteLength, 'x', 0o644, mtime)
401
- yield pax
402
- yield padding(pax.byteLength)
403
- headerName = encoder.encode(input.name).subarray(0, 100)
404
- }
405
- yield header(headerName, input.size, '0', mode, mtime)
543
+ yield* entryHead(input)
406
544
  if (input.body instanceof Blob || isChunks(input.body)) {
407
545
  let n = 0
408
546
  const chunks = input.body instanceof Blob ? input.body.stream() : input.body
package/src/cache/zstd.ts CHANGED
@@ -157,7 +157,7 @@ export const zstdEncoder = (): TransformStream<Uint8Array, Uint8Array> =>
157
157
  new CompressionStream('zstd') as unknown as TransformStream<Uint8Array, Uint8Array>
158
158
 
159
159
  /** Bytes in memory as a one-chunk stream, for a reader that takes a stream. */
160
- export const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
160
+ const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
161
161
  new ReadableStream({
162
162
  start(c) {
163
163
  c.enqueue(bytes)
package/src/cli/cache.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { Cache } from '../cache/index.js'
3
- import { flagHint, formatValue, refusedWord, seeHelp } from './help.js'
4
- import { nearest, parseDuration, parseSize } from '../util/index.js'
3
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
4
+ import { errorCode, nearest, parseDuration, parseSize } from '../util/index.js'
5
5
  import { acquireRunLock } from '../orchestrator/index.js'
6
6
  import { findWorkspaceRoot } from '../workspace/index.js'
7
7
  import {
@@ -56,15 +56,19 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
56
56
  return await pruneCmd(rest)
57
57
  case undefined:
58
58
  // A bare `vx cache prune` refuses too: the hint names a whole command.
59
- process.stderr.write(
60
- `vx cache: missing subcommand. Try \`vx cache prune --older-than 30d\` or \`vx cache prune --max-size 1G\`${seeHelp('cache')}\n`,
59
+ return refuse(
60
+ 'cache',
61
+ args,
62
+ `missing subcommand. Try \`vx cache prune --older-than 30d\` or \`vx cache prune --max-size 1G\`${seeHelp('cache')}`,
63
+ 'VX_E_USAGE',
61
64
  )
62
- return 1
63
65
  default:
64
- process.stderr.write(
65
- `vx cache: unknown subcommand: ${sub}${cacheSubHint(sub)}${seeHelp('cache')}\n`,
66
+ return refuse(
67
+ 'cache',
68
+ args,
69
+ `unknown subcommand: ${sub}${cacheSubHint(sub)}${seeHelp('cache')}`,
70
+ 'VX_E_USAGE',
66
71
  )
67
- return 1
68
72
  }
69
73
  }
70
74
 
@@ -153,17 +157,13 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
153
157
 
154
158
  async function pruneCmd(args: readonly string[]): Promise<number> {
155
159
  const parsed = parsePruneArgs(args)
156
- if (parsed.error) {
157
- process.stderr.write(`vx cache prune: ${parsed.error}\n`)
158
- return 1
159
- }
160
+ if (parsed.error) return refuse('cache prune', args, parsed.error, 'VX_E_USAGE')
160
161
  const cwd = process.cwd()
161
162
  let root: string
162
163
  try {
163
164
  root = await findWorkspaceRoot(cwd)
164
165
  } catch (err) {
165
- process.stderr.write(`vx cache prune: ${(err as Error).message}\n`)
166
- return 1
166
+ return refuse('cache prune', args, (err as Error).message, errorCode(err))
167
167
  }
168
168
  // Honor `--cache-dir`, `defineWorkspace({ cacheDir: '...' })` and a
169
169
  // `config` plugin's edit of it — `vx run` and `vx cache prune` must
@@ -361,7 +361,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
361
361
  names: ['--json'],
362
362
  value: false,
363
363
  outcome: 'refuse',
364
- vx: "use `--dry=json` for the plan, `--summarize[=<path>]` for the run's JSON record",
364
+ vx: "use `--format json` for the run's result on stdout, `--dry=json` for the plan",
365
365
  },
366
366
  {
367
367
  runner: 'turbo',
package/src/cli/help.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ColorSupport, paint as Paint } from '../orchestrator/index.js'
2
- import { CORE_VERBS, nearest } from '../util/index.js'
2
+ import { CORE_VERBS, errorDocument, maskedLine, nearest, wantsJson } from '../util/index.js'
3
3
 
4
4
  export async function printHelp(
5
5
  pluginCommands: readonly string[] = [],
@@ -109,12 +109,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
109
109
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
110
110
  ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
111
111
  ' vx lock [--check]',
112
- ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
112
+ ' vx init [--dry] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
113
113
  ' vx upgrade [TAG]',
114
114
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
115
115
  ' vx info [--format pretty|json] [--cache-dir <path>]',
116
116
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
117
- ' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
117
+ ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx completions bash|zsh|fish',
119
119
  ' vx help [VERB]',
120
120
  ' vx version',
@@ -161,6 +161,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
161
161
  ' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
162
162
  '',
163
163
  'Artifacts (for run):',
164
+ ' --format <fmt> pretty (default) | json: print the --summarize document',
165
+ ' on stdout when the run ends; every other line goes to stderr.',
164
166
  ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
165
167
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
166
168
  ' --report[=markdown] Print a markdown run report to stdout after the run.',
@@ -227,6 +229,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
227
229
  ' table, failures first. No re-execution, no cache probe.',
228
230
  ' --list[=N] List the N most recent runs (default 10) with run ids.',
229
231
  ' --failed Only failed runs: the latest one, or with --list the list.',
232
+ " --log <task> That task's output in the run (latest that ran it): a failure's",
233
+ ' or the cached log; none for an uncached pass.',
230
234
  ' --format <fmt> pretty (default) | json.',
231
235
  ' info, why and last:',
232
236
  ' --cache-dir <path> Read the cache a run with the same flag uses.',
@@ -235,14 +239,16 @@ export function helpText(pluginCommands: readonly string[] = []): string {
235
239
  ' vx init Generate vx.workspace.ts + one vx.config.ts per package',
236
240
  ' from package.json scripts. Unmappable settings become',
237
241
  ' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
238
- ' only vx.workspace.ts, declaring turbo() or nx().',
242
+ ' runs @vzn/vx-migrate, which asks native or keep.',
239
243
  ' --dry Print the generated files instead of writing them.',
240
244
  ' --force Overwrite existing vx.config.* files.',
241
245
  ' --mjs Write .mjs files (no type import) instead of .ts.',
246
+ ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
247
+ ' --keep Turbo or Nx: only vx.workspace.ts, declaring turbo() or nx().',
242
248
  ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
243
249
  ' test (executor, cache, telemetry, schedule, admit, commands,',
244
250
  ' project, graph, key).',
245
- ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
251
+ ' From turbo.json or an Nx project graph: `vx init` runs `@vzn/vx-migrate` (its own package).',
246
252
  '',
247
253
  'Shell completions:',
248
254
  ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
@@ -353,6 +359,7 @@ export const WATCH_REFUSED_FLAGS: readonly string[] = [
353
359
  '--report',
354
360
  '--report-file',
355
361
  '--verbosity',
362
+ '--format',
356
363
  ]
357
364
 
358
365
  /**
@@ -380,3 +387,21 @@ export function acceptedFlags(verb: string): string[] {
380
387
  }
381
388
 
382
389
  export { CORE_VERBS } from '../util/index.js'
390
+
391
+ /**
392
+ * A refusal a verb prints itself rather than throws: one line on stderr,
393
+ * exit 1, and under `--format json` / `--dry=json` the same as the error
394
+ * document on stdout that `bin.ts` prints for a thrown one (cli.md § Error
395
+ * codes), so an agent reads a code.
396
+ */
397
+ export function refuse(
398
+ verb: string,
399
+ args: readonly string[],
400
+ message: string,
401
+ code: string,
402
+ ): number {
403
+ const line = `vx ${verb}: ${message}`
404
+ process.stderr.write(`${line}\n`)
405
+ if (wantsJson(args)) process.stdout.write(errorDocument(code, maskedLine(line)))
406
+ return 1
407
+ }
package/src/cli/index.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  import { VERSION } from '../version.js'
5
5
  import { CORE_VERBS, flagHint, printHelp, refusedWord, seeHelp } from './help.js'
6
6
  import { FOREIGN_VERBS } from './foreign-flags.js'
7
- import { isUserError, MOVED_VERBS, nearest, UserError } from '../util/index.js'
7
+ import {
8
+ errorDocument,
9
+ isUserError,
10
+ maskedLine,
11
+ MOVED_VERBS,
12
+ nearest,
13
+ UserError,
14
+ wantsJson,
15
+ } from '../util/index.js'
8
16
 
9
17
  // Every verb is imported when invoked, `run` included, and so is the
10
18
  // plugin-verb lookup: each pulls in the orchestrator and the workspace
@@ -151,6 +159,14 @@ export async function run(argv: readonly string[]): Promise<number> {
151
159
  // both things: the verb is unknown HERE, and why the lookup could not
152
160
  // finish — a typo still reads as a typo, and a real plugin verb still
153
161
  // points at the file that broke it.
162
+ // Every answer below is "no such verb here": under --format json the
163
+ // agent reads it as one code, as a verb's own refusal gives one.
164
+ const unknown = (text: string, code = 'VX_E_UNKNOWN_COMMAND'): number => {
165
+ process.stderr.write(text)
166
+ if (wantsJson(rest))
167
+ process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
168
+ return 1
169
+ }
154
170
  const loadNote =
155
171
  resolved !== null && 'loadError' in resolved
156
172
  ? `\n (plugin verbs could not be looked up: vx.workspace failed to load: ${resolved.loadError})`
@@ -160,8 +176,7 @@ export async function run(argv: readonly string[]): Promise<number> {
160
176
  // workspace that declares a plugin verb of that name keeps it.
161
177
  const moved = MOVED_VERBS[command] ?? FOREIGN_VERBS[command]
162
178
  if (moved !== undefined) {
163
- process.stderr.write(`${moved}${loadNote}\n`)
164
- return 1
179
+ return unknown(`${moved}${loadNote}\n`)
165
180
  }
166
181
  // A task typed where the verb goes (`turbo build`, `nx build app`),
167
182
  // `turbo dev` included: a repo's own `dev` task beats the no-service note.
@@ -176,8 +191,7 @@ export async function run(argv: readonly string[]): Promise<number> {
176
191
  ).taskVerbHint(command, rest, process.cwd(), guess === '' && !command.startsWith('-'))
177
192
  : null
178
193
  if (task !== null) {
179
- process.stderr.write(`vx: ${task}\n`)
180
- return 1
194
+ return unknown(`vx: ${task}\n`)
181
195
  }
182
196
  if (command === 'serve' || command === 'dev') {
183
197
  // vx core is only a task runner — it has no service layer of its
@@ -186,13 +200,12 @@ export async function run(argv: readonly string[]): Promise<number> {
186
200
  // We keep this neutral hint for the common muscle-memory verbs, but
187
201
  // core names no specific plugin package: any package can provide
188
202
  // these.
189
- process.stderr.write(
203
+ return unknown(
190
204
  `vx: '${command}' is not a vx core command.\n` +
191
205
  ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
192
206
  ` distributed execution come from plugins — not core. See the plugin\n` +
193
207
  ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
194
208
  )
195
- return 1
196
209
  }
197
210
  // One line, as a verb's own unknown flag or subcommand gets; the full
198
211
  // help after a typo was a hundred lines past the hint that mattered.
@@ -208,16 +221,15 @@ export async function run(argv: readonly string[]): Promise<number> {
208
221
  if (command.startsWith('-')) {
209
222
  // `-v` / `-V` is another tool's version flag, far from both names.
210
223
  const flag = /^-v$/i.test(command) ? '--version' : nearest(command, ['--help', '--version'])
211
- process.stderr.write(
224
+ return unknown(
212
225
  `vx: unknown flag: ${command}${flag === undefined ? '' : ` (did you mean ${flag}?)`}; a verb's flags follow the verb (see \`vx help\`)\n`,
226
+ 'VX_E_USAGE',
213
227
  )
214
- return 1
215
228
  }
216
- process.stderr.write(
229
+ return unknown(
217
230
  `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
218
231
  (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
219
232
  )
220
- return 1
221
233
  }
222
234
  }
223
235
  }
package/src/cli/info.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  // from the orchestrator's `collectInfo` (doctor.ts), which `vx mcp` reads
5
5
  // too; this file parses the flags and renders the rows.
6
6
 
7
+ import os from 'node:os'
7
8
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
- import { flagHint, formatValue, refusedWord, seeHelp } from './help.js'
9
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
11
  import { formatBytes } from './format.js'
11
12
  import { MIN_BUN } from '../util/index.js'
@@ -44,10 +45,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
44
45
 
45
46
  export async function infoCmd(args: readonly string[]): Promise<number> {
46
47
  const parsed = parseInfoArgs(args)
47
- if (parsed.error) {
48
- process.stderr.write(`vx info: ${parsed.error}\n`)
49
- return 1
50
- }
48
+ if (parsed.error) return refuse('info', args, parsed.error, 'VX_E_USAGE')
51
49
  // `collectInfo` takes a resolved directory from `vx mcp` too, where one
52
50
  // not made yet is a workspace that never ran; the flag is the user's,
53
51
  // and a directory it names that is not there is a typo (item 900).
@@ -75,7 +73,7 @@ export function renderInfo(f: InfoFacts): string {
75
73
  ],
76
74
  ['git', f.git ?? '(not found)'],
77
75
  ['git status cache', renderGitStatusCache(f.gitStatusCache)],
78
- ['workspace root', f.workspaceRoot],
76
+ ['workspace root', tildePath(f.workspaceRoot)],
79
77
  ['projects', describeProjects(f)],
80
78
  // Only when a config did not load: the count above says "zero" for
81
79
  // it, and this row says which and why.
@@ -88,10 +86,16 @@ export function renderInfo(f: InfoFacts): string {
88
86
  ['plugins', describePlugins(f.plugins)],
89
87
  ['workers', describeWorkers(f.workers)],
90
88
  ['memory', describeMemory(f.memory)],
91
- ['cache dir', f.cacheDir],
92
- ['cache store', f.cacheStore ?? 'none: the cache dir holds the entries'],
89
+ ['cache dir', tildePath(f.cacheDir)],
90
+ [
91
+ 'cache store',
92
+ f.cacheStore === null ? 'none: the cache dir holds the entries' : tildePath(f.cacheStore),
93
+ ],
93
94
  ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
94
- ['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
95
+ ['cache entries', describeEntries(f)],
96
+ ...(f.cacheRetention !== null
97
+ ? ([['cache retention', describeRetention(f.cacheRetention)]] as [string, string][])
98
+ : []),
95
99
  // Only when there is something to say.
96
100
  ...(f.orphans.artifacts > 0
97
101
  ? ([
@@ -118,6 +122,31 @@ export function renderInfo(f: InfoFacts): string {
118
122
  return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
119
123
  }
120
124
 
125
+ /** `812 (1.2 GB)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
126
+ function describeEntries(f: InfoFacts): string {
127
+ const max = f.cacheRetention?.maxBytes
128
+ const size =
129
+ max === undefined
130
+ ? formatBytes(f.cacheBytes)
131
+ : `${formatBytes(f.cacheBytes)} of ${formatBytes(max)}, ${Math.round((f.cacheBytes / max) * 100)}%`
132
+ return `${f.cacheEntries} (${size})`
133
+ }
134
+
135
+ /** What a run's end evicts: `entries unused for 30d, and the oldest entries past 10G`. */
136
+ function describeRetention(r: NonNullable<InfoFacts['cacheRetention']>): string {
137
+ const parts = [
138
+ ...(r.olderThan !== undefined ? [`entries unused for ${r.olderThan}`] : []),
139
+ ...(r.maxSize !== undefined ? [`the oldest entries past ${r.maxSize}`] : []),
140
+ ]
141
+ return `each run evicts ${parts.join(', and ')}`
142
+ }
143
+
144
+ /** A path under the home directory as `~/…`: shorter, and no user name in a pasted report. */
145
+ function tildePath(p: string): string {
146
+ const home = os.homedir()
147
+ return p === home ? '~' : p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p
148
+ }
149
+
121
150
  /**
122
151
  * `available (3 tasks declare exec.sandbox)`, with `, untraced — <why>, so
123
152
  * the reads it denies go unreported` where no strace can trace, or