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