@vzn/vx 0.0.630 → 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.
- package/README.md +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
package/src/cache/tar-stream.ts
CHANGED
|
@@ -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
|
-
*
|
|
192
|
-
*
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
pendingPath = undefined
|
|
223
|
-
pendingSize = undefined
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
267
|
+
this.pendingSize = Number(s)
|
|
251
268
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -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 `--
|
|
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
|
-
'
|
|
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: `
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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',
|
|
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
|