@vzn/vx 0.0.21 → 0.0.23
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 +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cache/zstd.ts
CHANGED
|
@@ -8,9 +8,10 @@ import { CorruptArtifactError } from './layer.js'
|
|
|
8
8
|
* A decompressed artifact above this is refused as a zstd bomb rather than
|
|
9
9
|
* expanded into memory. 2 GiB comfortably exceeds any real build output while
|
|
10
10
|
* bounding a malicious/compromised remote's ability to OOM a victim who takes
|
|
11
|
-
* a cache hit.
|
|
11
|
+
* a cache hit. The default of `Cache`'s `artifactCeiling`, the one seam that
|
|
12
|
+
* lowers it (for a test: no test can produce 2 GiB of output).
|
|
12
13
|
*/
|
|
13
|
-
const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
|
|
14
|
+
export const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
|
|
@@ -49,31 +50,43 @@ export function zstdContentSize(b: Uint8Array): bigint | null {
|
|
|
49
50
|
/**
|
|
50
51
|
* Decompress a zstd artifact that DECLARES its content size, with a hard
|
|
51
52
|
* output ceiling: refused before a byte is allocated when the declaration
|
|
52
|
-
* is over the cap
|
|
53
|
+
* is over the cap. The re-check on the actual length below is a backstop
|
|
54
|
+
* against a decoder that stops validating the declaration, NOT a second
|
|
55
|
+
* live layer — see its comment. A frame with no
|
|
53
56
|
* declaration (a streamed producer's — vx's own, above 4 MiB) never comes
|
|
54
57
|
* here: `decodedTar` decodes it as a stream under the running count, so
|
|
55
58
|
* a sizeless bomb has nowhere to expand.
|
|
56
59
|
*/
|
|
57
|
-
async function zstdDecompressBounded(
|
|
58
|
-
|
|
60
|
+
async function zstdDecompressBounded(
|
|
61
|
+
compressed: Uint8Array,
|
|
62
|
+
hash: string,
|
|
63
|
+
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
64
|
+
): Promise<Uint8Array> {
|
|
65
|
+
assertDeclaredSize(compressed, hash, cap)
|
|
59
66
|
const out = await Bun.zstdDecompress(compressed)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
// UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
|
|
68
|
+
// above refuses any frame DECLARING more than the cap, and Bun refuses a
|
|
69
|
+
// frame whose declaration disagrees with its body ("Decompression
|
|
70
|
+
// failed" on a forged Frame_Content_Size, measured item 487) — so a frame
|
|
71
|
+
// that gets here declared <= cap and produced exactly that. The only way
|
|
72
|
+
// this fires again is a decoder that stops validating the declaration.
|
|
73
|
+
// That is what it is for; it is not a second live layer, and no test can
|
|
74
|
+
// reach it.
|
|
75
|
+
if (out.length > cap) {
|
|
76
|
+
throw new CorruptArtifactError(hash, `decompressed to ${out.length} bytes (> ${cap} cap)`)
|
|
65
77
|
}
|
|
66
78
|
return out
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
/** The pre-decompress half of the ceiling: the frame header's own claim, when it makes one. */
|
|
70
|
-
function assertDeclaredSize(
|
|
82
|
+
function assertDeclaredSize(
|
|
83
|
+
compressed: Uint8Array,
|
|
84
|
+
hash: string,
|
|
85
|
+
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
86
|
+
): bigint | null {
|
|
71
87
|
const declared = zstdContentSize(compressed)
|
|
72
|
-
if (declared !== null && declared > BigInt(
|
|
73
|
-
throw new CorruptArtifactError(
|
|
74
|
-
hash,
|
|
75
|
-
`declares ${declared} decompressed bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
|
|
76
|
-
)
|
|
88
|
+
if (declared !== null && declared > BigInt(cap)) {
|
|
89
|
+
throw new CorruptArtifactError(hash, `declares ${declared} decompressed bytes (> ${cap} cap)`)
|
|
77
90
|
}
|
|
78
91
|
return declared
|
|
79
92
|
}
|
|
@@ -115,18 +128,29 @@ const oneChunk = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
|
|
|
115
128
|
export async function decodedTar(
|
|
116
129
|
source: Uint8Array | Bun.BunFile,
|
|
117
130
|
hash: string,
|
|
131
|
+
/**
|
|
132
|
+
* The decompression ceiling: `Cache` passes its `artifactCeiling`, whose
|
|
133
|
+
* default IS this module's. Reaching the real one needs an artifact that
|
|
134
|
+
* expands past 2 GiB, which no test can produce — so before item 487 the
|
|
135
|
+
* sizeless-bomb defense this module's comments promise had nothing
|
|
136
|
+
* asserting it, while the declared-size half was pinned by a forged
|
|
137
|
+
* header costing 20 bytes.
|
|
138
|
+
*/
|
|
139
|
+
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
118
140
|
): Promise<ReadableStream<Uint8Array>> {
|
|
119
141
|
if (source instanceof Uint8Array) {
|
|
120
|
-
if (assertDeclaredSize(source, hash) === null)
|
|
121
|
-
|
|
142
|
+
if (assertDeclaredSize(source, hash, cap) === null) {
|
|
143
|
+
return zstdDecodeStream(new Blob([source]), hash, cap)
|
|
144
|
+
}
|
|
145
|
+
return oneChunk(await zstdDecompressBounded(source, hash, cap))
|
|
122
146
|
}
|
|
123
147
|
if (source.size <= STREAM_DECODE_FROM) {
|
|
124
148
|
const bytes = await source.bytes()
|
|
125
|
-
if (assertDeclaredSize(bytes, hash) === null) return zstdDecodeStream(source, hash)
|
|
126
|
-
return oneChunk(await zstdDecompressBounded(bytes, hash))
|
|
149
|
+
if (assertDeclaredSize(bytes, hash, cap) === null) return zstdDecodeStream(source, hash, cap)
|
|
150
|
+
return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
|
|
127
151
|
}
|
|
128
|
-
assertDeclaredSize(await source.slice(0, 32).bytes(), hash)
|
|
129
|
-
return zstdDecodeStream(source, hash)
|
|
152
|
+
assertDeclaredSize(await source.slice(0, 32).bytes(), hash, cap)
|
|
153
|
+
return zstdDecodeStream(source, hash, cap)
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
/**
|
|
@@ -135,7 +159,11 @@ export async function decodedTar(
|
|
|
135
159
|
* past the cap here either — the count runs as bytes are produced, before
|
|
136
160
|
* any of them reach the reader's next entry.
|
|
137
161
|
*/
|
|
138
|
-
function zstdDecodeStream(
|
|
162
|
+
function zstdDecodeStream(
|
|
163
|
+
source: Blob,
|
|
164
|
+
hash: string,
|
|
165
|
+
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
166
|
+
): ReadableStream<Uint8Array> {
|
|
139
167
|
let total = 0
|
|
140
168
|
return source
|
|
141
169
|
.stream()
|
|
@@ -144,13 +172,8 @@ function zstdDecodeStream(source: Blob, hash: string): ReadableStream<Uint8Array
|
|
|
144
172
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
145
173
|
transform(chunk, controller) {
|
|
146
174
|
total += chunk.byteLength
|
|
147
|
-
if (total >
|
|
148
|
-
controller.error(
|
|
149
|
-
new CorruptArtifactError(
|
|
150
|
-
hash,
|
|
151
|
-
`decompresses past ${MAX_DECOMPRESSED_ARTIFACT_BYTES} bytes (cap)`,
|
|
152
|
-
),
|
|
153
|
-
)
|
|
175
|
+
if (total > cap) {
|
|
176
|
+
controller.error(new CorruptArtifactError(hash, `decompresses past ${cap} bytes (cap)`))
|
|
154
177
|
return
|
|
155
178
|
}
|
|
156
179
|
controller.enqueue(chunk)
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
2
2
|
import { seeHelp } from './help.js'
|
|
3
|
-
import {
|
|
3
|
+
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
4
|
+
import { acquireRunLock } from '../orchestrator/index.js'
|
|
4
5
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
5
6
|
import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
6
7
|
import { formatBytes } from './format.js'
|
|
7
8
|
|
|
8
9
|
// parseSize moved to `util` (the orchestrator's resource resolver needs it
|
|
9
10
|
// and can't import cli); re-exported here so existing callers are unchanged.
|
|
10
|
-
export { parseSize } from '../util/index.js'
|
|
11
|
+
export { parseDuration, parseSize } from '../util/index.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `vx cache stats` is a habit from the other runners, and vx's answer is a
|
|
15
|
+
* different VERB rather than a missing one — saying so where the user typed
|
|
16
|
+
* it beats sending them to a help screen that lists only `prune` (walked the
|
|
17
|
+
* first-run path, 2026-09-20). A typo of `prune` itself gets the same
|
|
18
|
+
* nearest-neighbour hint a task, flag or project name gets.
|
|
19
|
+
*/
|
|
20
|
+
const CACHE_ELSEWHERE: Record<string, string> = {
|
|
21
|
+
stats: '`vx info` reports the cache directory, its entry count and its size',
|
|
22
|
+
status: '`vx info` reports the cache directory, its entry count and its size',
|
|
23
|
+
size: '`vx info` reports the cache directory, its entry count and its size',
|
|
24
|
+
entries: '`vx info` reports the cache directory, its entry count and its size',
|
|
25
|
+
dir: '`vx info` prints the cache directory this workspace uses',
|
|
26
|
+
path: '`vx info` prints the cache directory this workspace uses',
|
|
27
|
+
clean: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
|
|
28
|
+
clear: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
|
|
29
|
+
rm: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
|
|
30
|
+
delete: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
|
|
31
|
+
evict: '`vx cache prune` is the eviction verb (`--older-than`, `--max-size`)',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cacheSubHint(sub: string): string {
|
|
35
|
+
const elsewhere = CACHE_ELSEWHERE[sub]
|
|
36
|
+
if (elsewhere !== undefined) return ` — ${elsewhere}`
|
|
37
|
+
const best = nearest(sub, ['prune'])
|
|
38
|
+
return best === undefined ? '' : `. Did you mean ${best}?`
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
export async function cacheCmd(args: readonly string[]): Promise<number> {
|
|
13
42
|
const [sub, ...rest] = args
|
|
@@ -18,7 +47,9 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
|
|
|
18
47
|
process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
|
|
19
48
|
return 1
|
|
20
49
|
default:
|
|
21
|
-
process.stderr.write(
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
`vx cache: unknown subcommand: ${sub}${cacheSubHint(sub)}${seeHelp('cache')}\n`,
|
|
52
|
+
)
|
|
22
53
|
return 1
|
|
23
54
|
}
|
|
24
55
|
}
|
|
@@ -110,8 +141,23 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
110
141
|
// `config` plugin's edit of it — `vx run` and `vx cache prune` must
|
|
111
142
|
// operate on the same directory or prune silently no-ops against the
|
|
112
143
|
// wrong path.
|
|
113
|
-
|
|
144
|
+
// A dry run only reads: it never resets the index (item 896).
|
|
145
|
+
const dir = await cliCacheDir(root, parsed.cacheDir)
|
|
146
|
+
const cache = parsed.dryRun === true ? Cache.inspect(dir) : new Cache(dir)
|
|
147
|
+
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
148
|
+
// refused up front with the directory named, as a run refuses it, rather
|
|
149
|
+
// than dying in the first DELETE with SQLite's "readonly database" and a
|
|
150
|
+
// stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
|
|
151
|
+
// run only reads, and reads a read-only cache fine.
|
|
152
|
+
if (!parsed.dryRun) cache.assertWritable()
|
|
114
153
|
noteSchemaReset(cache, warnToStderr)
|
|
154
|
+
// A prune beside a run on this workspace evicts what the run has just
|
|
155
|
+
// probed as hits, whose `accessed_at` bumps it has not flushed yet; the
|
|
156
|
+
// run then re-runs those tasks (upstream survey, nx#36688). So a prune
|
|
157
|
+
// takes the run's lock and waits its turn. A dry run deletes nothing.
|
|
158
|
+
const releaseRunLock = parsed.dryRun
|
|
159
|
+
? async (): Promise<void> => {}
|
|
160
|
+
: await acquireRunLock(root, { log: warnToStderr })
|
|
115
161
|
try {
|
|
116
162
|
const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
|
|
117
163
|
if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
|
|
@@ -128,23 +174,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
128
174
|
)
|
|
129
175
|
} finally {
|
|
130
176
|
cache.close()
|
|
177
|
+
await releaseRunLock()
|
|
131
178
|
}
|
|
132
179
|
return 0
|
|
133
180
|
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
|
|
137
|
-
* has always been case-insensitive; this matched only lowercase, so `30D`
|
|
138
|
-
* was rejected while `1GB` and `1gb` both worked).
|
|
139
|
-
*/
|
|
140
|
-
export function parseDuration(input: string): number | null {
|
|
141
|
-
const m = input.match(/^(\d+)([smhd])$/i)
|
|
142
|
-
if (!m) return null
|
|
143
|
-
const n = parseDecimalInt(m[1]!)
|
|
144
|
-
if (n === null) return null
|
|
145
|
-
// Lowercase before the switch: with the /i flag an uppercase `M` would
|
|
146
|
-
// otherwise fall through to the days branch.
|
|
147
|
-
const unit = m[2]!.toLowerCase()
|
|
148
|
-
const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
|
|
149
|
-
return n * mult
|
|
150
|
-
}
|
package/src/cli/format.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
export function formatBytes(n: number): string {
|
|
7
|
-
if (n < 1024) return `${n} B`
|
|
8
|
-
const units = ['KB', 'MB', 'GB', 'TB']
|
|
9
|
-
let v = n / 1024
|
|
10
|
-
for (const u of units) {
|
|
11
|
-
if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
|
|
12
|
-
v /= 1024
|
|
13
|
-
}
|
|
14
|
-
return `${v.toFixed(0)} PB`
|
|
15
|
-
}
|
|
1
|
+
// `formatBytes` moved to util/size.ts beside the parsers (item 658): a run
|
|
2
|
+
// names what the workspace's `cacheRetention` evicted, and the orchestrator
|
|
3
|
+
// may not import the CLI.
|
|
4
|
+
export { formatBytes } from '../util/index.js'
|
package/src/cli/help.ts
CHANGED
|
@@ -11,8 +11,12 @@ export function printHelp(pluginCommands: readonly string[] = [], verb?: string)
|
|
|
11
11
|
export function verbHelpText(verb: string): string {
|
|
12
12
|
const blocks = helpText().split('\n\n')
|
|
13
13
|
const title = blocks[0]!
|
|
14
|
+
// The verb goes into a RegExp, so it is escaped: `vx 'r.n' --help` is an
|
|
15
|
+
// unknown verb, not a pattern that matches `vx run` and answers with a
|
|
16
|
+
// help cut for a verb that does not exist.
|
|
17
|
+
const quoted = verb.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
18
|
const names = (line: string): boolean =>
|
|
15
|
-
new RegExp(`^\\s*vx ${
|
|
19
|
+
new RegExp(`^\\s*vx ${quoted}( |$)`).test(line) ||
|
|
16
20
|
line.includes(`(for ${verb})`) ||
|
|
17
21
|
line.includes(`(for ${verb} `)
|
|
18
22
|
const kept: string[] = []
|
|
@@ -54,7 +58,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
54
58
|
' --filter <pat> pnpm-style filter (repeatable). Examples:',
|
|
55
59
|
' foo, @scope/*, ./packages/foo, foo..., ...foo,',
|
|
56
60
|
' foo^..., !foo, [<since>]',
|
|
57
|
-
' --affected[=<base>] Sugar for --filter "[<base>]". Default base =',
|
|
61
|
+
' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
|
|
58
62
|
' origin/HEAD (falls back to HEAD~1).',
|
|
59
63
|
" pkg#task Run a specific project's task directly.",
|
|
60
64
|
'',
|
|
@@ -76,10 +80,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
76
80
|
' --timeout <ms> Default per-task timeout for tasks without their own exec.timeout',
|
|
77
81
|
' (above VX_TASK_TIMEOUT env + workspace timeout). Kills + fails',
|
|
78
82
|
' a runaway task. Per-task exec.timeout always wins.',
|
|
83
|
+
' --continue[=<mode>] What a failed task takes down: never | deps-ok (default, skips',
|
|
84
|
+
' its dependents) | always (bare form; dependents run, never save).',
|
|
79
85
|
' --frozen Load configs from vx-lock.json (CI; pair with vx lock --check).',
|
|
80
86
|
' --output-logs <mode> full | errors-only | hash-only | none — overrides the flow default',
|
|
81
|
-
' --download <mode> all (default) | toplevel | none — where remote outputs land',
|
|
82
87
|
' (focused without --all/--filter/--affected, broad with, full in CI).',
|
|
88
|
+
' --download <mode> all (default) | toplevel | none — where remote outputs land.',
|
|
83
89
|
' --verbosity <n> 0 (default) or 1+: print a per-task summary table after the run.',
|
|
84
90
|
'',
|
|
85
91
|
'Planning (for run — skips execution):',
|
|
@@ -89,6 +95,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
89
95
|
'Artifacts (for run):',
|
|
90
96
|
' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
|
|
91
97
|
' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
|
|
98
|
+
' --report[=markdown] Print a markdown run report to stdout after the run.',
|
|
99
|
+
' --report-file <path> Append that report to <path> (for $GITHUB_STEP_SUMMARY).',
|
|
100
|
+
' --tag <k=v> Label this invocation (repeatable); recorded on its history row.',
|
|
92
101
|
'',
|
|
93
102
|
'Extensions (plugins):',
|
|
94
103
|
' vx core runs tasks in-process and nothing else. A dashboard, remote',
|
|
@@ -115,6 +124,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
115
124
|
' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
|
|
116
125
|
'',
|
|
117
126
|
' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
|
|
127
|
+
' Cache statistics — directory, entry count, size — are part of `vx info`.',
|
|
118
128
|
'',
|
|
119
129
|
'Introspection:',
|
|
120
130
|
' vx show List every project: name, dir, declared task count.',
|
package/src/cli/index.ts
CHANGED
|
@@ -67,7 +67,7 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
67
67
|
// may own it (`VxPlugin.commands`). Core verbs were matched above, so
|
|
68
68
|
// nothing here can shadow them.
|
|
69
69
|
const resolved = await resolvePluginCommand(command)
|
|
70
|
-
if (resolved !== null && !('loadError' in resolved)) {
|
|
70
|
+
if (resolved !== null && !('loadError' in resolved) && !('declaredVerbs' in resolved)) {
|
|
71
71
|
const code = await resolved.command.run(rest, resolved.ctx)
|
|
72
72
|
// A plugin is a boundary: a JS-authored verb that resolves nothing
|
|
73
73
|
// would reach `process.exit(undefined)` and read as SUCCESS. A verb
|
|
@@ -110,8 +110,21 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
110
110
|
)
|
|
111
111
|
return 1
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// One line, as a verb's own unknown flag or subcommand gets; the full
|
|
114
|
+
// help after a typo was a hundred lines past the hint that mattered.
|
|
115
|
+
// The verbs this workspace's plugins declare are verbs HERE, so they
|
|
116
|
+
// join the "did you mean" set, and when nothing is close the second
|
|
117
|
+
// line says where a verb can come from: `vx mpc` in a workspace
|
|
118
|
+
// declaring `mcp` read as a plain unknown command, and `vx mcp`
|
|
119
|
+
// before the plugin was declared said nothing about the file that
|
|
120
|
+
// would declare it (2026-09-20).
|
|
121
|
+
const declaredVerbs =
|
|
122
|
+
resolved !== null && 'declaredVerbs' in resolved ? resolved.declaredVerbs : []
|
|
123
|
+
const guess = didYouMeanVerb(command, declaredVerbs)
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
|
|
126
|
+
(guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
|
|
127
|
+
)
|
|
115
128
|
return 1
|
|
116
129
|
}
|
|
117
130
|
}
|
|
@@ -145,11 +158,24 @@ function wantsHelp(command: string, rest: readonly string[]): boolean {
|
|
|
145
158
|
return own.includes('--help') || own.includes('-h')
|
|
146
159
|
}
|
|
147
160
|
|
|
148
|
-
/** ` Did you mean run?` for a verb within two edits of a core one
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
function didYouMeanVerb(verb: string): string {
|
|
153
|
-
const best = nearest(verb, CORE_VERBS)
|
|
161
|
+
/** ` Did you mean run?` for a verb within two edits of a core one, or of a
|
|
162
|
+
* verb this workspace's plugins declare — the same hint a task or flag typo
|
|
163
|
+
* gets. The plugin verbs come from the lookup that just failed, so they
|
|
164
|
+
* cost no second load. */
|
|
165
|
+
function didYouMeanVerb(verb: string, pluginVerbsHere: readonly string[] = []): string {
|
|
166
|
+
const best = nearest(verb, [...CORE_VERBS, ...pluginVerbsHere])
|
|
154
167
|
return best === undefined ? '' : `. Did you mean ${best}?`
|
|
155
168
|
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where a verb that is neither core nor declared COULD come from. Only when
|
|
172
|
+
* nothing is close enough to guess: after a plain typo the guess is the
|
|
173
|
+
* answer, and a second line would bury it.
|
|
174
|
+
*/
|
|
175
|
+
function verbSourceNote(resolved: unknown, declaredVerbs: readonly string[]): string {
|
|
176
|
+
if (declaredVerbs.length > 0) {
|
|
177
|
+
return ` This workspace's plugins declare: ${[...declaredVerbs].sort().join(', ')}.\n`
|
|
178
|
+
}
|
|
179
|
+
const here = resolved === null ? 'there is no workspace here' : 'this workspace declares none'
|
|
180
|
+
return ` A plugin declared in vx.workspace.ts can add verbs; ${here}.\n`
|
|
181
|
+
}
|
package/src/cli/info.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
|
|
8
8
|
import { seeHelp } from './help.js'
|
|
9
|
-
import { parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
9
|
+
import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
10
10
|
import { formatBytes } from './format.js'
|
|
11
|
+
import { MIN_BUN } from '../util/index.js'
|
|
11
12
|
|
|
12
13
|
export interface InfoArgs {
|
|
13
14
|
format: 'pretty' | 'json'
|
|
@@ -16,7 +17,7 @@ export interface InfoArgs {
|
|
|
16
17
|
error?: string
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
20
21
|
const out: InfoArgs = { format: 'pretty' }
|
|
21
22
|
for (let i = 0; i < args.length; i++) {
|
|
22
23
|
const a = args[i]
|
|
@@ -46,6 +47,10 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
|
|
|
46
47
|
process.stderr.write(`vx info: ${parsed.error}\n`)
|
|
47
48
|
return 1
|
|
48
49
|
}
|
|
50
|
+
// `collectInfo` takes a resolved directory from `vx mcp` too, where one
|
|
51
|
+
// not made yet is a workspace that never ran; the flag is the user's,
|
|
52
|
+
// and a directory it names that is not there is a typo (item 900).
|
|
53
|
+
if (parsed.cacheDir !== undefined) namedCacheDir(parsed.cacheDir)
|
|
49
54
|
const facts = await collectInfo(process.cwd(), {
|
|
50
55
|
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
51
56
|
warn: warnToStderr,
|
|
@@ -59,11 +64,26 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
|
|
|
59
64
|
export function renderInfo(f: InfoFacts): string {
|
|
60
65
|
const rows: [string, string][] = [
|
|
61
66
|
['vx', f.vx],
|
|
62
|
-
|
|
67
|
+
// The prose stays in the rendered row; `f.bun` itself is the bare
|
|
68
|
+
// version a script reads (doctor.ts says why).
|
|
69
|
+
[
|
|
70
|
+
'bun',
|
|
71
|
+
f.bunSupported
|
|
72
|
+
? f.bun
|
|
73
|
+
: `${f.bun} — unsupported, vx needs >= ${MIN_BUN.join('.')}; answers may be wrong`,
|
|
74
|
+
],
|
|
63
75
|
['git', f.git ?? '(not found)'],
|
|
64
76
|
['git status cache', renderGitStatusCache(f.gitStatusCache)],
|
|
65
77
|
['workspace root', f.workspaceRoot],
|
|
66
|
-
['projects',
|
|
78
|
+
['projects', describeProjects(f)],
|
|
79
|
+
// Only when a config did not load: the count above says "zero" for
|
|
80
|
+
// it, and this row says which and why.
|
|
81
|
+
...(f.configErrors.length > 0
|
|
82
|
+
? ([['config errors', f.configErrors.map((e) => `${e.path}: ${e.message}`).join('; ')]] as [
|
|
83
|
+
string,
|
|
84
|
+
string,
|
|
85
|
+
][])
|
|
86
|
+
: []),
|
|
67
87
|
['plugins', describePlugins(f.plugins)],
|
|
68
88
|
['workers', describeWorkers(f.workers)],
|
|
69
89
|
['memory', describeMemory(f.memory)],
|
|
@@ -79,14 +99,29 @@ export function renderInfo(f: InfoFacts): string {
|
|
|
79
99
|
],
|
|
80
100
|
] as [string, string][])
|
|
81
101
|
: []),
|
|
82
|
-
|
|
102
|
+
// Task runs, not invocations (which `vx last` calls runs): the count
|
|
103
|
+
// the hits are a share of.
|
|
104
|
+
['task runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
|
|
83
105
|
['flaky tasks', describeFlakyTasks(f.flakyTasks)],
|
|
106
|
+
['sandbox', describeSandbox(f.sandbox)],
|
|
84
107
|
['vx-lock.json', f.lockfile ? 'yes' : 'no'],
|
|
85
108
|
]
|
|
86
109
|
const labelW = Math.max(...rows.map(([label]) => label.length))
|
|
87
110
|
return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
|
|
88
111
|
}
|
|
89
112
|
|
|
113
|
+
/**
|
|
114
|
+
* `available (3 tasks declare exec.sandbox)`, or `unavailable — <why>; 3
|
|
115
|
+
* tasks declare exec.sandbox and will fail`: a declared sandbox whose
|
|
116
|
+
* runtime cannot start fails the task, so the doctor names it before a run
|
|
117
|
+
* does.
|
|
118
|
+
*/
|
|
119
|
+
function describeSandbox(s: InfoFacts['sandbox']): string {
|
|
120
|
+
const declared = `${s.declared} task${s.declared === 1 ? '' : 's'} declare${s.declared === 1 ? 's' : ''} exec.sandbox`
|
|
121
|
+
if (s.available) return `available (${declared})`
|
|
122
|
+
return `unavailable — ${s.reason}; ${declared}${s.declared > 0 ? ' and will fail' : ''}`
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
/** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — vx.workspace.ts (4 cores)`. */
|
|
91
126
|
export function describeWorkers(w: InfoFacts['workers']): string {
|
|
92
127
|
const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
|
|
@@ -109,7 +144,14 @@ function trimCores(n: number): string {
|
|
|
109
144
|
}
|
|
110
145
|
|
|
111
146
|
/** `2 — app#test (3 of 7 runs failed on unchanged inputs); api#e2e (1 of 4)`, or `none`. */
|
|
112
|
-
|
|
147
|
+
function describeProjects(f: InfoFacts): string {
|
|
148
|
+
const tasks = `${f.tasks} task${f.tasks === 1 ? '' : 's'}`
|
|
149
|
+
const n = f.configErrors.length
|
|
150
|
+
if (n === 0) return `${f.projects} (${tasks})`
|
|
151
|
+
return `${f.projects} (${tasks} · ${n} config${n === 1 ? '' : 's'} did not load)`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
|
|
113
155
|
if (tasks.length === 0) return 'none'
|
|
114
156
|
const parts = tasks.map(
|
|
115
157
|
(t, i) =>
|
|
@@ -118,7 +160,7 @@ export function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
|
|
|
118
160
|
return `${tasks.length} — ${parts.join('; ')}`
|
|
119
161
|
}
|
|
120
162
|
|
|
121
|
-
|
|
163
|
+
function describePlugins(
|
|
122
164
|
plugins: ReadonlyArray<{ name: string; seams: readonly string[] }>,
|
|
123
165
|
): string {
|
|
124
166
|
if (plugins.length === 0) return 'none'
|
package/src/cli/init.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { seeHelp } from './help.js'
|
|
|
8
8
|
import {
|
|
9
9
|
applyMigration,
|
|
10
10
|
findWorkspaceRoot,
|
|
11
|
+
type LoadReads,
|
|
11
12
|
listProjects,
|
|
12
13
|
loadWorkspace,
|
|
13
14
|
migrateScripts,
|
|
@@ -39,16 +40,26 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
39
40
|
process.stderr.write(`vx init: ${parsed.error}\n`)
|
|
40
41
|
return 1
|
|
41
42
|
}
|
|
42
|
-
const
|
|
43
|
-
const
|
|
43
|
+
const reads: LoadReads = new Map()
|
|
44
|
+
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
45
|
+
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
44
46
|
// `init` reads scripts only; a runner's own config beside them is the
|
|
45
47
|
// richer source (dependsOn, inputs, outputs) and was ignored without a
|
|
46
48
|
// word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
|
|
47
49
|
// TODOs and none of the edges turbo.json already declared.
|
|
48
50
|
const notes: string[] = []
|
|
49
|
-
|
|
51
|
+
// Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
|
|
52
|
+
let turbo: string | undefined
|
|
53
|
+
for (const name of ['turbo.json', 'turbo.jsonc']) {
|
|
54
|
+
if (await Bun.file(path.join(root, name)).exists()) {
|
|
55
|
+
turbo = name
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (turbo !== undefined) {
|
|
50
60
|
notes.push(
|
|
51
|
-
|
|
61
|
+
`${turbo} found and not read — ` +
|
|
62
|
+
'`bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
|
|
52
63
|
'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
|
|
53
64
|
)
|
|
54
65
|
} else if (
|
|
@@ -57,7 +68,8 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
57
68
|
) {
|
|
58
69
|
notes.push(
|
|
59
70
|
'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
|
|
60
|
-
'exported project graph'
|
|
71
|
+
'exported project graph, or `plugins: [nx()]` from @vzn/vx-migrate runs it with ' +
|
|
72
|
+
'nothing written',
|
|
61
73
|
)
|
|
62
74
|
}
|
|
63
75
|
return applyMigration({
|