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