@vzn/vx 0.0.354 → 0.0.356
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 -77
- package/package.json +5 -5
- package/src/cache/archive.ts +3 -11
- package/src/cache/cache.ts +2 -2
- package/src/cache/git-inputs.ts +2 -1
- package/src/cache/inputs.ts +3 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +1 -1
- package/src/cli/upgrade.ts +2 -3
- package/src/config.ts +3 -2
- package/src/exec/env.ts +0 -16
- package/src/exec/kill-tree.ts +0 -24
- package/src/exec/local-executor.ts +2 -3
- package/src/exec/runner.ts +24 -22
- package/src/exec/sandbox-binds.ts +39 -15
- package/src/exec/sandbox-runtime.ts +43 -4
- package/src/exec/sandbox-violations.ts +92 -15
- package/src/orchestrator/events.ts +8 -3
- package/src/orchestrator/execute-task.ts +14 -1
- package/src/orchestrator/run-lock.ts +1 -31
- package/src/orchestrator/sandbox-request.ts +1 -1
- package/src/util/real-path.ts +2 -5
- package/src/util/which.ts +2 -7
- package/src/workspace/config-cache.ts +15 -0
- package/src/workspace/config-schema.ts +8 -1
- package/src/workspace/migrate-scripts.ts +24 -0
- package/src/workspace/migration.ts +20 -4
- package/src/workspace/package-graph.ts +21 -13
- package/src/workspace/project-loader.ts +61 -38
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
shellQuote,
|
|
49
49
|
withForwardArgs,
|
|
50
50
|
signalExitCode,
|
|
51
|
+
stopSignal,
|
|
51
52
|
spawnFailureText,
|
|
52
53
|
streamToString,
|
|
53
54
|
resourceUsageToCpuRss,
|
|
@@ -63,7 +64,13 @@ import {
|
|
|
63
64
|
UserError,
|
|
64
65
|
xxh3hex,
|
|
65
66
|
} from '../util/index.js'
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
bindableReads,
|
|
69
|
+
bindableWrites,
|
|
70
|
+
buildCustomConfig,
|
|
71
|
+
scratchWrites,
|
|
72
|
+
widenedEntries,
|
|
73
|
+
} from './sandbox-binds.js'
|
|
67
74
|
import {
|
|
68
75
|
atOrUnder,
|
|
69
76
|
isMountableLiteral,
|
|
@@ -550,6 +557,9 @@ export async function initSandbox(opts?: {
|
|
|
550
557
|
/** Whether any task of the run grants `gitConfig`: SRT reads it run-wide, so it is set per wrap (B-41). */
|
|
551
558
|
gitConfig?: boolean
|
|
552
559
|
}): Promise<void> {
|
|
560
|
+
// This run owns the session now; a reset an earlier run deferred to its
|
|
561
|
+
// last server would tear it down under this run's tasks (M-25).
|
|
562
|
+
resetDeferred = false
|
|
553
563
|
// A reset a server's exit started unawaited: a watch cycle stops its
|
|
554
564
|
// server and starts its run at once, and an init under that reset
|
|
555
565
|
// found SRT up, hot-reloaded it, and had it torn down after (item 884).
|
|
@@ -947,6 +957,8 @@ export async function wrapSandboxedCommand(
|
|
|
947
957
|
server?: boolean
|
|
948
958
|
/** Trace the command's `openat` calls to descriptor TRACE_FD (Linux; `wantsStraceDetection`). */
|
|
949
959
|
trace?: 'plain' | 'seccomp'
|
|
960
|
+
/** Trace with `-y`, each descriptor's path printed: a read under a widened grant is judged. */
|
|
961
|
+
tracePaths?: boolean
|
|
950
962
|
},
|
|
951
963
|
): Promise<{
|
|
952
964
|
wrapped: string
|
|
@@ -1004,7 +1016,7 @@ export async function wrapSandboxedCommand(
|
|
|
1004
1016
|
const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
|
|
1005
1017
|
const grouped =
|
|
1006
1018
|
process.platform === 'linux'
|
|
1007
|
-
? ownGroupCommand(tag, inTmp, args.trace)
|
|
1019
|
+
? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
|
|
1008
1020
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1009
1021
|
const inner = [
|
|
1010
1022
|
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
@@ -1082,6 +1094,7 @@ function ownGroupCommand(
|
|
|
1082
1094
|
tag: string,
|
|
1083
1095
|
userCommand: string,
|
|
1084
1096
|
trace?: 'plain' | 'seccomp',
|
|
1097
|
+
tracePaths = false,
|
|
1085
1098
|
): { command: string; forwards: boolean; traced: boolean } {
|
|
1086
1099
|
// `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
|
|
1087
1100
|
// bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
|
|
@@ -1131,6 +1144,10 @@ function ownGroupCommand(
|
|
|
1131
1144
|
'-DD',
|
|
1132
1145
|
'-f',
|
|
1133
1146
|
...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
1147
|
+
// A read through a directory's descriptor (`find`, `grep -r`) names
|
|
1148
|
+
// only the entry; `-y` prints the path it opened. 40% slower on 2,000
|
|
1149
|
+
// opens, so only where a widened grant's reads are judged.
|
|
1150
|
+
...(tracePaths ? ['-y'] : []),
|
|
1134
1151
|
'-qq',
|
|
1135
1152
|
'-e',
|
|
1136
1153
|
// A process's cwd moves on `chdir` and starts as its parent's at the
|
|
@@ -1477,9 +1494,31 @@ async function runSandboxedOnce(
|
|
|
1477
1494
|
// kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
|
|
1478
1495
|
// one gets the slow form rather than no detection.
|
|
1479
1496
|
const useStrace = await wantsStraceDetection()
|
|
1497
|
+
// Before the spawn: what the task creates under a widened grant is its own.
|
|
1498
|
+
const widened = useStrace ? widenedEntries(args.config.allowWrite) : undefined
|
|
1480
1499
|
const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
|
|
1481
|
-
await wrapSandboxedCommand({
|
|
1500
|
+
await wrapSandboxedCommand({
|
|
1501
|
+
...args,
|
|
1502
|
+
...(useStrace ? { trace: useStrace } : {}),
|
|
1503
|
+
...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
|
|
1504
|
+
})
|
|
1482
1505
|
const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
|
|
1506
|
+
// A stop that landed during the awaits above leaves nothing to kill yet:
|
|
1507
|
+
// spawned now, the task ran after the teardown swept the run's children.
|
|
1508
|
+
if (args.signal?.aborted === true) {
|
|
1509
|
+
releaseBridges(tag)
|
|
1510
|
+
takeRecords()
|
|
1511
|
+
const signal = stopSignal(args.signal.reason)
|
|
1512
|
+
return {
|
|
1513
|
+
exitCode: signalExitCode(signal),
|
|
1514
|
+
durationMs: Date.now() - start,
|
|
1515
|
+
stdout: '',
|
|
1516
|
+
stderr: '',
|
|
1517
|
+
signal,
|
|
1518
|
+
violations: [],
|
|
1519
|
+
tracerFailed: false,
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1483
1522
|
// Beside the task directories, which every sandbox replaces with its own:
|
|
1484
1523
|
// in the shared temp dir a concurrent task read this log, every path
|
|
1485
1524
|
// this task opened (L-25).
|
|
@@ -1628,7 +1667,7 @@ async function runSandboxedOnce(
|
|
|
1628
1667
|
process.platform === 'linux'
|
|
1629
1668
|
? [
|
|
1630
1669
|
...(straceLog
|
|
1631
|
-
? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
|
|
1670
|
+
? await parseStraceViolations(straceLog, args, baselines, widened).catch(() => [])
|
|
1632
1671
|
: []),
|
|
1633
1672
|
...refusedWrites(
|
|
1634
1673
|
// Keyed by what SRT wrapped, the in-sandbox group wrapper
|
|
@@ -50,6 +50,25 @@ const STRACE_RESUMED_RE = new RegExp(
|
|
|
50
50
|
)
|
|
51
51
|
/** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
|
|
52
52
|
const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
|
|
53
|
+
/**
|
|
54
|
+
* An `openat` that succeeded, and not for writing alone: a read. Placed by
|
|
55
|
+
* the path `-y` prints for the descriptor it returned; without one, only
|
|
56
|
+
* from the cwd (`AT_FDCWD`) or by an absolute path.
|
|
57
|
+
*/
|
|
58
|
+
const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
|
|
59
|
+
const OPEN_READ_RE = new RegExp(
|
|
60
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
|
|
61
|
+
)
|
|
62
|
+
const OPEN_READ_UNFINISHED_RE = new RegExp(
|
|
63
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
|
|
64
|
+
)
|
|
65
|
+
const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
|
|
66
|
+
|
|
67
|
+
/** Where a successful read opened: `-y`'s path, else the call's own when placeable. */
|
|
68
|
+
function readPath(dirfd: string, rawPath: string, opened: string | undefined): string | undefined {
|
|
69
|
+
if (opened !== undefined) return cStringPath(opened)
|
|
70
|
+
return dirfd === 'AT_FDCWD' || rawPath.startsWith('/') ? rawPath : undefined
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
const C_ESCAPES: Record<string, number> = { n: 10, t: 9, r: 13, v: 11, f: 12, a: 7, b: 8 }
|
|
55
74
|
|
|
@@ -89,6 +108,8 @@ export interface DeniedCall {
|
|
|
89
108
|
syscall: string
|
|
90
109
|
rawPath: string
|
|
91
110
|
errno: string
|
|
111
|
+
/** A read that SUCCEEDED (`errno` empty): asked for by `deniedCalls`' `reads`. */
|
|
112
|
+
read?: true
|
|
92
113
|
/**
|
|
93
114
|
* The directory a relative `rawPath` was opened from, when the trace
|
|
94
115
|
* shows the process (or one it was forked from) changed into one; absent
|
|
@@ -132,8 +153,11 @@ type Op = { chdir: string } | { lost: true } | { call: number }
|
|
|
132
153
|
* detector, and a synthetic trace pins the split-line shapes deterministically
|
|
133
154
|
* where an end-to-end run only produces them when strace happens to interleave.
|
|
134
155
|
*/
|
|
135
|
-
export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
136
|
-
const pending = new Map<
|
|
156
|
+
export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
|
|
157
|
+
const pending = new Map<
|
|
158
|
+
string,
|
|
159
|
+
{ syscall: string; rawPath: string; read?: true; dirfd?: string }
|
|
160
|
+
>()
|
|
137
161
|
const out: DeniedCall[] = []
|
|
138
162
|
const ops = new Map<string, Op[]>()
|
|
139
163
|
// A clone with CLONE_FS (every thread) shares its creator's cwd, so a
|
|
@@ -162,6 +186,15 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
162
186
|
const opener =
|
|
163
187
|
line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
|
|
164
188
|
if (!opener) {
|
|
189
|
+
if (reads && line.includes('openat(')) {
|
|
190
|
+
const m = OPEN_READ_RE.exec(line)
|
|
191
|
+
if (m !== null) {
|
|
192
|
+
const at = readPath(m[2]!, cStringPath(m[3]!), m[4])
|
|
193
|
+
if (at !== undefined)
|
|
194
|
+
denied(m[1]!, { syscall: 'openat', rawPath: at, errno: '', read: true })
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
165
198
|
if (
|
|
166
199
|
cwd !== undefined &&
|
|
167
200
|
(line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
|
|
@@ -180,7 +213,12 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
180
213
|
unfinished[2] !== undefined &&
|
|
181
214
|
unfinished[3] !== undefined
|
|
182
215
|
) {
|
|
183
|
-
pending.set(unfinished[1], {
|
|
216
|
+
pending.set(unfinished[1], {
|
|
217
|
+
syscall: unfinished[2],
|
|
218
|
+
rawPath: cStringPath(unfinished[3]),
|
|
219
|
+
...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
|
|
220
|
+
...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
|
|
221
|
+
})
|
|
184
222
|
continue
|
|
185
223
|
}
|
|
186
224
|
const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
|
|
@@ -190,8 +228,14 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
190
228
|
const resumed = STRACE_RESUMED_RE.exec(line)
|
|
191
229
|
// Only a resume that carries a DENIAL is a violation; a successful
|
|
192
230
|
// resume just retires the pending entry.
|
|
231
|
+
const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
|
|
193
232
|
if (held !== undefined && resumed?.[3] !== undefined) {
|
|
194
|
-
denied(resumedOk[1], {
|
|
233
|
+
denied(resumedOk[1], { syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
|
|
234
|
+
} else if (held !== undefined && fd !== null) {
|
|
235
|
+
const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
|
|
236
|
+
if (at !== undefined) {
|
|
237
|
+
denied(resumedOk[1], { syscall: held.syscall, rawPath: at, errno: '', read: true })
|
|
238
|
+
}
|
|
195
239
|
}
|
|
196
240
|
continue
|
|
197
241
|
}
|
|
@@ -264,6 +308,8 @@ export async function parseStraceViolations(
|
|
|
264
308
|
logPath: string,
|
|
265
309
|
args: SandboxedRunArgs,
|
|
266
310
|
baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
|
|
311
|
+
/** `widenedEntries` taken as the task started: no entry, no read parse. */
|
|
312
|
+
widened: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
|
|
267
313
|
): Promise<SandboxViolation[]> {
|
|
268
314
|
const text = await Bun.file(logPath).text()
|
|
269
315
|
if (text.length === 0) return []
|
|
@@ -281,29 +327,60 @@ export async function parseStraceViolations(
|
|
|
281
327
|
),
|
|
282
328
|
)
|
|
283
329
|
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
330
|
+
// A read under a widened write grant's directory is never refused, so it
|
|
331
|
+
// is reported when it succeeds: an entry that was there at the start and
|
|
332
|
+
// no grant covers is an input the key never saw, and so is the
|
|
333
|
+
// directory's listing while it holds one. The granted files and what the
|
|
334
|
+
// task made itself stay readable.
|
|
335
|
+
const written = new Set(args.config.allowWrite.map((p) => toRealPath(absolutize(p))))
|
|
336
|
+
const undeclared = (abs: string): boolean => {
|
|
337
|
+
for (const [dir, entries] of widened) {
|
|
338
|
+
if (abs === dir)
|
|
339
|
+
return [...entries].some((e) => !isUnderAny(e, written) && !isUnderAny(e, allowAbs))
|
|
340
|
+
if (!atOrUnder(abs, dir)) continue
|
|
341
|
+
const top = path.join(dir, path.relative(dir, abs).split(path.sep)[0]!)
|
|
342
|
+
return entries.has(top) && !isUnderAny(abs, written)
|
|
343
|
+
}
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
284
346
|
|
|
285
347
|
const seen = new Set<string>()
|
|
286
348
|
const out: SandboxViolation[] = []
|
|
287
|
-
for (const { syscall, rawPath, errno, dir } of deniedCalls(
|
|
349
|
+
for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
|
|
350
|
+
text,
|
|
351
|
+
baselines.cwd,
|
|
352
|
+
widened.size > 0,
|
|
353
|
+
)) {
|
|
288
354
|
const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
|
|
289
355
|
// Only report paths under the workspace-root deny anchor — system
|
|
290
356
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
291
357
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
292
358
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
293
359
|
if (isUnderAny(abs, allowAbs)) continue
|
|
360
|
+
if (read === true && !undeclared(abs)) continue
|
|
294
361
|
const key = `${syscall}|${abs}`
|
|
295
362
|
if (seen.has(key)) continue
|
|
296
363
|
seen.add(key)
|
|
297
|
-
out.push(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
364
|
+
out.push(
|
|
365
|
+
read === true
|
|
366
|
+
? {
|
|
367
|
+
line: `${syscall}(${rawPath}) read under a write grant's directory, granted no read [${abs}]`,
|
|
368
|
+
timestamp: new Date(),
|
|
369
|
+
target: abs,
|
|
370
|
+
path: abs,
|
|
371
|
+
ignorable: ['read'],
|
|
372
|
+
}
|
|
373
|
+
: {
|
|
374
|
+
line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
|
|
375
|
+
timestamp: new Date(),
|
|
376
|
+
target: abs,
|
|
377
|
+
path: abs,
|
|
378
|
+
// The trace is `-e trace=openat`, and an openat is a read or a
|
|
379
|
+
// write depending on flags the trace does not carry — so either
|
|
380
|
+
// list can silence it.
|
|
381
|
+
ignorable: ['read', 'write'],
|
|
382
|
+
},
|
|
383
|
+
)
|
|
307
384
|
}
|
|
308
385
|
return out
|
|
309
386
|
}
|
|
@@ -52,7 +52,12 @@ export interface EventBus {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function createEventBus(): EventBus {
|
|
55
|
-
|
|
55
|
+
// Replaced, never mutated: an emit walks the list it started with, so a
|
|
56
|
+
// subscriber that unsubscribes itself (an embedder's `off()` on
|
|
57
|
+
// `run:end`) no longer shifts the next one past the event — the terminal
|
|
58
|
+
// renderer after it missed `run:end` (C-77). A subscriber added during
|
|
59
|
+
// an emit hears the next event, not this one.
|
|
60
|
+
let subscribers: readonly RunEventSubscriber[] = []
|
|
56
61
|
return {
|
|
57
62
|
emit(event) {
|
|
58
63
|
// Synchronous, in-subscription-order fan-out. Ordering is part of
|
|
@@ -79,10 +84,10 @@ export function createEventBus(): EventBus {
|
|
|
79
84
|
}
|
|
80
85
|
},
|
|
81
86
|
subscribe(subscriber) {
|
|
82
|
-
subscribers
|
|
87
|
+
subscribers = [...subscribers, subscriber]
|
|
83
88
|
return () => {
|
|
84
89
|
const i = subscribers.indexOf(subscriber)
|
|
85
|
-
if (i >= 0) subscribers.
|
|
90
|
+
if (i >= 0) subscribers = subscribers.toSpliced(i, 1)
|
|
86
91
|
}
|
|
87
92
|
},
|
|
88
93
|
}
|
|
@@ -493,11 +493,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
493
493
|
// The local executor keeps its own timer (it signals the process group).
|
|
494
494
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
495
495
|
let timeoutFired = false
|
|
496
|
+
// The attempt's listener on the run's stop signal, taken off once the
|
|
497
|
+
// attempt settles. Left on, every task of the run added one: the list
|
|
498
|
+
// grew to the task count, and each add scans it for a duplicate, ~100 ms
|
|
499
|
+
// of a 1,000-task cold run.
|
|
500
|
+
let unlistenStop: (() => void) | undefined
|
|
496
501
|
function requestSignal(): AbortSignal {
|
|
497
502
|
const stop = new AbortController()
|
|
498
503
|
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
504
|
+
unlistenStop?.()
|
|
505
|
+
unlistenStop = undefined
|
|
499
506
|
if (args.stopSignal?.aborted === true) abort()
|
|
500
|
-
else args.stopSignal
|
|
507
|
+
else if (args.stopSignal !== undefined) {
|
|
508
|
+
const run = args.stopSignal
|
|
509
|
+
run.addEventListener('abort', abort, { once: true })
|
|
510
|
+
unlistenStop = () => run.removeEventListener('abort', abort)
|
|
511
|
+
}
|
|
501
512
|
if (effectiveTimeout !== undefined) {
|
|
502
513
|
clearTimeout(timeoutTimer)
|
|
503
514
|
timeoutFired = false
|
|
@@ -801,6 +812,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
801
812
|
})
|
|
802
813
|
.finally(() => {
|
|
803
814
|
clearTimeout(timeoutTimer)
|
|
815
|
+
unlistenStop?.()
|
|
816
|
+
unlistenStop = undefined
|
|
804
817
|
flushMasked()
|
|
805
818
|
})
|
|
806
819
|
endExec()
|
|
@@ -240,7 +240,7 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
240
240
|
await mkdir(staging)
|
|
241
241
|
try {
|
|
242
242
|
await writeFile(path.join(staging, entry), '')
|
|
243
|
-
await
|
|
243
|
+
await rename(staging, lockDir)
|
|
244
244
|
return entry
|
|
245
245
|
} catch (err) {
|
|
246
246
|
await rm(staging, { recursive: true, force: true })
|
|
@@ -250,36 +250,6 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
const WIN32 = process.platform === 'win32'
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Windows renames no directory onto another, even an empty one: EPERM
|
|
257
|
-
* where POSIX gives ENOTEMPTY, which read as a refusal and ran every
|
|
258
|
-
* contender unlocked. A name that exists is taken; an empty one (a
|
|
259
|
-
* holder's leave that lost its rmdir) is removed and the rename tried
|
|
260
|
-
* once more, as POSIX's rename onto it would replace it.
|
|
261
|
-
*/
|
|
262
|
-
async function renameWin32(staging: string, lockDir: string): Promise<void> {
|
|
263
|
-
try {
|
|
264
|
-
await rename(staging, lockDir)
|
|
265
|
-
return
|
|
266
|
-
} catch (err) {
|
|
267
|
-
const code = (err as NodeJS.ErrnoException).code
|
|
268
|
-
if (code !== 'EPERM' && code !== 'EACCES') throw err
|
|
269
|
-
const empty = await rmdir(lockDir).then(
|
|
270
|
-
() => true,
|
|
271
|
-
() => false,
|
|
272
|
-
)
|
|
273
|
-
if (!empty) throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
|
|
274
|
-
}
|
|
275
|
-
await rename(staging, lockDir).catch((err: NodeJS.ErrnoException) => {
|
|
276
|
-
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
|
277
|
-
throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
|
|
278
|
-
}
|
|
279
|
-
throw err
|
|
280
|
-
})
|
|
281
|
-
}
|
|
282
|
-
|
|
283
253
|
/**
|
|
284
254
|
* Unlink one taking's entry, then the directory if that left it empty.
|
|
285
255
|
* Gone already is not an error (another waiter reclaimed it first); a
|
|
@@ -193,7 +193,7 @@ export async function sandboxRequestFor(
|
|
|
193
193
|
// character class, so vx's own workspace wall matched nothing (B-65).
|
|
194
194
|
// A `*` or `?` is one too, and its grants matched the project's siblings.
|
|
195
195
|
const home = toRealPath(node.projectDir)
|
|
196
|
-
if (
|
|
196
|
+
if (/[[\]*?]/.test(home)) {
|
|
197
197
|
throw new UserError(
|
|
198
198
|
`exec.sandbox: ${home} holds [, ], * or ?, which the sandbox runtime reads as a ` +
|
|
199
199
|
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
package/src/util/real-path.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A path's canonical spelling
|
|
5
|
-
*
|
|
6
|
-
* and every other reader say `runneradmin`, so one file had two
|
|
7
|
-
* spellings and a key or a comparison that folds the path split on
|
|
8
|
-
* them (O-16). The native call asks the OS for the final path.
|
|
4
|
+
* A path's canonical spelling: the OS's own answer for the final path,
|
|
5
|
+
* so a key or a comparison that folds the path sees one spelling (O-16).
|
|
9
6
|
*/
|
|
10
7
|
export function realPath(p: string): string {
|
|
11
8
|
return realpathSync.native(p)
|
package/src/util/which.ts
CHANGED
|
@@ -48,13 +48,8 @@ export function executablePath(name: string): string {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* The argv that runs `script` in the task shell: `sh -c
|
|
52
|
-
* macOS, and Bun's own shell (`bun exec`) on Windows, which ships no `sh`
|
|
53
|
-
* (docs/design/windows-2026-09.md § Decisions 1). Bun's shell reads the
|
|
54
|
-
* same `&&`, `|`, `$(…)` and `VAR=x cmd` syntax that `bun run` uses there.
|
|
51
|
+
* The argv that runs `script` in the task shell: `sh -c`.
|
|
55
52
|
*/
|
|
56
53
|
export function shellArgv(script: string): string[] {
|
|
57
|
-
return
|
|
58
|
-
? [process.execPath, 'exec', script]
|
|
59
|
-
: [executablePath('sh'), '-c', script]
|
|
54
|
+
return [executablePath('sh'), '-c', script]
|
|
60
55
|
}
|
|
@@ -545,6 +545,21 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
545
545
|
return h.toString(16).padStart(16, '0')
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
/** `configEvalKeyFromClosure` over identities already in hand; null when one is missing. */
|
|
549
|
+
export function configEvalKeyFromIdentities(a: {
|
|
550
|
+
closure: readonly string[]
|
|
551
|
+
identities: ReadonlyMap<string, string>
|
|
552
|
+
workspaceFingerprint: string
|
|
553
|
+
}): string | null {
|
|
554
|
+
let h = keySeed(a.workspaceFingerprint)
|
|
555
|
+
for (const file of a.closure) {
|
|
556
|
+
const id = a.identities.get(file)
|
|
557
|
+
if (id === undefined) return null
|
|
558
|
+
h = xxh3(`${file}\0${id}`, h)
|
|
559
|
+
}
|
|
560
|
+
return h.toString(16).padStart(16, '0')
|
|
561
|
+
}
|
|
562
|
+
|
|
548
563
|
/**
|
|
549
564
|
* Every file a config imports by relative specifier, transitively, outside
|
|
550
565
|
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
@@ -282,7 +282,14 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
282
282
|
const where = `${configPath}: tasks.${name}`
|
|
283
283
|
assertTaskName(name, configPath)
|
|
284
284
|
if (!task || typeof task !== 'object') {
|
|
285
|
-
|
|
285
|
+
// package.json's shape, `name: 'command'`, was refused without the
|
|
286
|
+
// object that holds the command.
|
|
287
|
+
const raw: unknown = task
|
|
288
|
+
throw new UserError(
|
|
289
|
+
typeof raw === 'string' && raw.trim() !== ''
|
|
290
|
+
? `${where} must be an object — a command is { exec: { command: ${JSON.stringify(raw)} } }`
|
|
291
|
+
: `${where} must be an object`,
|
|
292
|
+
)
|
|
286
293
|
}
|
|
287
294
|
assertKnownFields(task, TASK_FIELDS, where)
|
|
288
295
|
const exec = (task as { exec?: unknown }).exec
|
|
@@ -343,6 +343,16 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
343
343
|
return false
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The hooks a package's build hides in when it has no `build` script:
|
|
348
|
+
* react-navigation's twelve packages build in `prepack: bob build`, their
|
|
349
|
+
* root's `build` (`lerna run prepack`) runs the members and is left out,
|
|
350
|
+
* and the repo mapped with no build at all.
|
|
351
|
+
*/
|
|
352
|
+
const LIFECYCLE_BUILD_HOOKS = ['prepack', 'prepublishOnly', 'prepublish', 'prepare']
|
|
353
|
+
const BUILDER =
|
|
354
|
+
/(?:^|[\s;&|(])(?:bob build|tsc|tsup|tsdown|rollup|vite build|babel|unbuild|esbuild|webpack|microbundle|pkgroll|bunchee|preconstruct build)(?:\s|$)/
|
|
355
|
+
|
|
346
356
|
/** The workspace flags that move a package manager off the package it runs in. */
|
|
347
357
|
const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
|
|
348
358
|
const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
|
|
@@ -481,6 +491,7 @@ export function migrateScripts(
|
|
|
481
491
|
// and maps.
|
|
482
492
|
const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
|
|
483
493
|
const notes: string[] = []
|
|
494
|
+
const lifecycleBuilds: [string, string, string][] = []
|
|
484
495
|
// The rest check the whole repo (`lint: oxlint .`, `test: vitest`):
|
|
485
496
|
// `vx run lint` found no project in remix, wagmi or element-plus. Such a
|
|
486
497
|
// script maps onto the root, when the root has a name (vx skips a
|
|
@@ -585,6 +596,12 @@ export function migrateScripts(
|
|
|
585
596
|
return why === null ? [] : [[n, why] as const]
|
|
586
597
|
})
|
|
587
598
|
const names = runnable.filter((n) => taskNameProblem(n) === null)
|
|
599
|
+
if (meta !== rootMeta && !('build' in scripts)) {
|
|
600
|
+
const hook = LIFECYCLE_BUILD_HOOKS.find(
|
|
601
|
+
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h] as string),
|
|
602
|
+
)
|
|
603
|
+
if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
|
|
604
|
+
}
|
|
588
605
|
if (runnable.length === 0) continue
|
|
589
606
|
const hasBuild = names.includes('build')
|
|
590
607
|
const has = (n: string): boolean => names.includes(n)
|
|
@@ -681,6 +698,13 @@ export function migrateScripts(
|
|
|
681
698
|
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
682
699
|
}
|
|
683
700
|
}
|
|
701
|
+
if (lifecycleBuilds.length > 0) {
|
|
702
|
+
const [name, hook, command] = lifecycleBuilds[0]!
|
|
703
|
+
const more = lifecycleBuilds.length - 1
|
|
704
|
+
notes.push(
|
|
705
|
+
`${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
684
708
|
breakBuildCycles(projects, metas)
|
|
685
709
|
pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
|
|
686
710
|
return {
|
|
@@ -402,10 +402,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
402
402
|
for (const f of replaced) report.push(` ${relPosix(root, f)}`)
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
|
-
const firstTask =
|
|
406
|
-
plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
|
|
407
|
-
plan.projects[0]?.tasks[0]?.name ??
|
|
408
|
-
'build'
|
|
405
|
+
const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
|
|
409
406
|
// A run of tasks none of which caches is never a hit, and a first try
|
|
410
407
|
// that runs twice to see the cache work saw it run twice.
|
|
411
408
|
if (!cached && [...todos.keys()].some(isCacheTodo)) {
|
|
@@ -442,6 +439,25 @@ function inGitWorkTree(root: string): boolean {
|
|
|
442
439
|
}
|
|
443
440
|
}
|
|
444
441
|
|
|
442
|
+
/** Names that change the repo or the world rather than check it. */
|
|
443
|
+
const NOT_A_TRY = /^(clean|reset|nuke|release|publish|deploy|version|format|fix)(?:$|[:\-_.])/
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The task the `next:` line runs: `build`, else the first task that
|
|
447
|
+
* neither serves nor changes anything, else the first. react-navigation, whose
|
|
448
|
+
* packages build in `prepack`, was told `vx run clean --all` (D-105).
|
|
449
|
+
*/
|
|
450
|
+
function nextTask(tasks: readonly GeneratedTask[]): string {
|
|
451
|
+
const names = tasks.filter((t) => t.task !== null).map((t) => t.name)
|
|
452
|
+
const fit = tasks.find(
|
|
453
|
+
(t) =>
|
|
454
|
+
t.task !== null &&
|
|
455
|
+
!NOT_A_TRY.test(t.name) &&
|
|
456
|
+
(t.task['exec'] as { persistent?: unknown } | undefined)?.persistent === undefined,
|
|
457
|
+
)
|
|
458
|
+
return names.includes('build') ? 'build' : (fit?.name ?? names[0] ?? 'build')
|
|
459
|
+
}
|
|
460
|
+
|
|
445
461
|
/**
|
|
446
462
|
* How the user runs vx, for the report's `next:` line. After `npx vx init`
|
|
447
463
|
* or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
|
|
@@ -60,7 +60,6 @@ export function buildPackageGraph(
|
|
|
60
60
|
// insurance against a future rule that does depend on it; it is not
|
|
61
61
|
// what the stability rests on.
|
|
62
62
|
const directDeps = new Map<string, string[]>()
|
|
63
|
-
const reachDeps = new Map<string, string[]>()
|
|
64
63
|
const order = new Map<string, Set<string>>()
|
|
65
64
|
const peers = new Map<string, Set<string>>()
|
|
66
65
|
const sorted = [...projects].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
@@ -128,22 +127,30 @@ export function buildPackageGraph(
|
|
|
128
127
|
if (!own.has(peer) && !reaches(peer, p.name)) own.add(peer)
|
|
129
128
|
}
|
|
130
129
|
}
|
|
131
|
-
for (const p of sorted)
|
|
132
|
-
const own = order.get(p.name)!
|
|
133
|
-
directDeps.set(p.name, [...own].sort())
|
|
134
|
-
reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
|
|
135
|
-
}
|
|
130
|
+
for (const p of sorted) directDeps.set(p.name, [...order.get(p.name)!].sort())
|
|
136
131
|
|
|
137
|
-
//
|
|
132
|
+
// REACH and its reverse (who declares X as a workspace dep) are read only
|
|
133
|
+
// by the transitive questions below, which an unscoped run never asks:
|
|
134
|
+
// built on the first one, as the closures are.
|
|
135
|
+
const reachDeps = new Map<string, string[]>()
|
|
138
136
|
const directDependents = new Map<string, string[]>()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
let reachBuilt = false
|
|
138
|
+
const buildReach = (): void => {
|
|
139
|
+
if (reachBuilt) return
|
|
140
|
+
reachBuilt = true
|
|
141
|
+
for (const p of sorted) {
|
|
142
|
+
const own = order.get(p.name)!
|
|
143
|
+
reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
|
|
144
|
+
}
|
|
145
|
+
for (const [name, deps] of reachDeps) {
|
|
146
|
+
for (const d of deps) {
|
|
147
|
+
const arr = directDependents.get(d)
|
|
148
|
+
if (arr) arr.push(name)
|
|
149
|
+
else directDependents.set(d, [name])
|
|
150
|
+
}
|
|
144
151
|
}
|
|
152
|
+
for (const arr of directDependents.values()) arr.sort()
|
|
145
153
|
}
|
|
146
|
-
for (const arr of directDependents.values()) arr.sort()
|
|
147
154
|
|
|
148
155
|
// Set-union DFS closures are O(P²) entries on dense layered graphs
|
|
149
156
|
// (same disease the scheduler's reachOf had — 68 ms at 1090
|
|
@@ -224,6 +231,7 @@ export function buildPackageGraph(
|
|
|
224
231
|
return (name) => {
|
|
225
232
|
const cached = memo.get(name)
|
|
226
233
|
if (cached) return cached
|
|
234
|
+
buildReach()
|
|
227
235
|
if (closures === undefined) closures = bitsetClosures(edges)
|
|
228
236
|
if (closures === null) {
|
|
229
237
|
const result = reachableFrom(name, edges)
|