@vzn/vx 0.0.353 → 0.0.354
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/package.json +5 -5
- package/src/cache/git-inputs.ts +78 -32
- package/src/cache/inputs.ts +27 -11
- package/src/cli/cache.ts +4 -1
- package/src/cli/show.ts +13 -6
- package/src/cli/why.ts +5 -2
- package/src/exec/env.ts +7 -2
- package/src/exec/sandbox-binds.ts +48 -13
- package/src/exec/sandbox-runtime.ts +5 -3
- package/src/exec/sandbox-violations.ts +5 -1
- package/src/orchestrator/doctor.ts +8 -10
- package/src/orchestrator/plugin-host.ts +5 -1
- package/src/orchestrator/run.ts +23 -1
- package/src/orchestrator/sandbox-request.ts +14 -3
- package/src/orchestrator/shell-verdict.ts +4 -0
- package/src/orchestrator/summary.ts +6 -3
- package/src/workspace/config-schema.ts +34 -1
- package/src/workspace/migrate-scripts.ts +55 -0
- package/src/workspace/project-loader.ts +34 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.354",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.354",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.354",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.354",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.354"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -360,7 +360,7 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
360
360
|
)
|
|
361
361
|
}
|
|
362
362
|
const { text, undecodable } = decodeGitZ(proc.stdout)
|
|
363
|
-
const parsed = parseLsFilesOutput(text, undecodable)
|
|
363
|
+
const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
|
|
364
364
|
const nested = expandNestedRepos(cwd, parsed.files, parsed.gitlinks)
|
|
365
365
|
parsed.files = nested.files
|
|
366
366
|
parsed.undecodable.push(...nested.undecodable)
|
|
@@ -445,15 +445,22 @@ export function fileIdentity(mode: string, oid: string): string {
|
|
|
445
445
|
// `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
|
|
446
446
|
// quoting is off, so a bare path containing a literal tab still cannot
|
|
447
447
|
// match the fixed-form prefix. Both answers come from ONE spawn.
|
|
448
|
-
|
|
448
|
+
//
|
|
449
|
+
// `debug`, for a `--debug` listing: each record's recorded size (aligned
|
|
450
|
+
// with `records`) in, and each regular stage-0 entry's raw OID and size out.
|
|
451
|
+
function parseLsFilesOutput(
|
|
452
|
+
records: readonly string[],
|
|
453
|
+
undecodableRecords: ReadonlySet<string>,
|
|
454
|
+
debug?: { sizes: readonly number[]; indexed: Map<string, { oid: string; size: number }> },
|
|
455
|
+
): GitLsResult {
|
|
449
456
|
const files: string[] = []
|
|
450
457
|
const oids = new Map<string, string>()
|
|
451
458
|
const flagged = new Set<string>()
|
|
452
459
|
const gitlinks = new Set<string>()
|
|
453
460
|
const undecodable: string[] = []
|
|
454
|
-
if (out.length === 0) return { files, oids, flagged, gitlinks, undecodable }
|
|
455
461
|
// NUL-separated; trailing NUL produces an empty segment we skip.
|
|
456
|
-
for (
|
|
462
|
+
for (let i = 0; i < records.length; i++) {
|
|
463
|
+
const record = records[i]!
|
|
457
464
|
if (record.length === 0) continue
|
|
458
465
|
const m = LS_FILES_STAGE_RE.exec(record)
|
|
459
466
|
const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
|
|
@@ -464,6 +471,9 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
464
471
|
const stage = m[4]!
|
|
465
472
|
if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
|
|
466
473
|
oids.set(filePath, fileIdentity(mode, m[3]!))
|
|
474
|
+
if (debug !== undefined && mode !== '120000') {
|
|
475
|
+
debug.indexed.set(filePath, { oid: m[3]!, size: debug.sizes[i]! })
|
|
476
|
+
}
|
|
467
477
|
} else if (mode === '160000') {
|
|
468
478
|
gitlinks.add(filePath)
|
|
469
479
|
}
|
|
@@ -477,21 +487,23 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
477
487
|
}
|
|
478
488
|
|
|
479
489
|
/**
|
|
480
|
-
* `ls-files -s -v -z --debug` as the plain `-s -v -z`
|
|
481
|
-
* the index records for each
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
490
|
+
* `ls-files -s -v -z --debug` as the plain `-s -v -z` records, plus the size
|
|
491
|
+
* the index records for each, aligned. With `-z`, each record's NUL is
|
|
492
|
+
* followed by its five newline-ended debug lines (ctime, mtime, dev/ino,
|
|
493
|
+
* uid/gid, size/flags), then the next record. The records go to
|
|
494
|
+
* `parseLsFilesOutput` as they are: joining them for it to split again,
|
|
495
|
+
* a regex per size line and a second regex per record were ~14 ms of a
|
|
496
|
+
* 12,905-file listing.
|
|
485
497
|
*/
|
|
486
|
-
function
|
|
487
|
-
|
|
498
|
+
function splitLsDebug(run: GitRun): {
|
|
499
|
+
records: string[]
|
|
500
|
+
sizes: number[]
|
|
488
501
|
undecodable: Set<string>
|
|
489
|
-
indexed: Map<string, { oid: string; size: number }>
|
|
490
502
|
} {
|
|
491
503
|
const segs = run.stdout.split('\0')
|
|
492
504
|
const records: string[] = []
|
|
505
|
+
const sizes: number[] = []
|
|
493
506
|
const undecodable = new Set<string>()
|
|
494
|
-
const indexed = new Map<string, { oid: string; size: number }>()
|
|
495
507
|
let record = segs[0]!
|
|
496
508
|
let recordRaw = record
|
|
497
509
|
for (let i = 1; i <= segs.length; i++) {
|
|
@@ -504,7 +516,13 @@ function stripLsDebug(run: GitRun): {
|
|
|
504
516
|
const nl = seg.indexOf('\n', at)
|
|
505
517
|
if (nl < 0) at = -1
|
|
506
518
|
else {
|
|
507
|
-
if (k === 4)
|
|
519
|
+
if (k === 4) {
|
|
520
|
+
const field = seg.indexOf('size: ', at)
|
|
521
|
+
if (field >= 0 && field < nl) {
|
|
522
|
+
const n = parseInt(seg.slice(field + 6, nl), 10)
|
|
523
|
+
if (!Number.isNaN(n)) size = n
|
|
524
|
+
}
|
|
525
|
+
}
|
|
508
526
|
at = nl + 1
|
|
509
527
|
}
|
|
510
528
|
}
|
|
@@ -512,16 +530,13 @@ function stripLsDebug(run: GitRun): {
|
|
|
512
530
|
}
|
|
513
531
|
if (record.length > 0) {
|
|
514
532
|
records.push(record)
|
|
533
|
+
sizes.push(size)
|
|
515
534
|
if (run.undecodable.has(recordRaw)) undecodable.add(record)
|
|
516
|
-
const m = LS_FILES_STAGE_RE.exec(record)
|
|
517
|
-
if (m !== null && m[4] === '0' && (m[2] === '100644' || m[2] === '100755')) {
|
|
518
|
-
indexed.set(record.slice(m[0].length), { oid: m[3]!, size })
|
|
519
|
-
}
|
|
520
535
|
}
|
|
521
536
|
record = rest
|
|
522
537
|
recordRaw = seg ?? ''
|
|
523
538
|
}
|
|
524
|
-
return {
|
|
539
|
+
return { records, sizes, undecodable }
|
|
525
540
|
}
|
|
526
541
|
|
|
527
542
|
/** Blob sizes learned once and kept: a blob's size is fixed for its OID. `Cache` is one. */
|
|
@@ -546,9 +561,16 @@ export interface BlobSizeMemo {
|
|
|
546
561
|
async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
|
|
547
562
|
const { trusted, indexed } = enumeration
|
|
548
563
|
const wanted = new Set<string>()
|
|
564
|
+
// Each trusted path's index entry, found once: a second lookup by path
|
|
565
|
+
// in the check below was half of this pass on a 12,905-file tree.
|
|
566
|
+
const rels: string[] = []
|
|
567
|
+
const entries: Array<{ oid: string; size: number }> = []
|
|
549
568
|
for (const rel of trusted.keys()) {
|
|
550
569
|
const entry = indexed.get(rel)
|
|
551
|
-
if (entry
|
|
570
|
+
if (entry === undefined) continue // a symlink: its blob is its target string
|
|
571
|
+
rels.push(rel)
|
|
572
|
+
entries.push(entry)
|
|
573
|
+
wanted.add(entry.oid)
|
|
552
574
|
}
|
|
553
575
|
if (wanted.size === 0) return
|
|
554
576
|
const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
|
|
@@ -569,12 +591,11 @@ async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo)
|
|
|
569
591
|
for (const [oid, n] of learned) sizes.set(oid, n)
|
|
570
592
|
memo?.rememberBlobSizes(learned)
|
|
571
593
|
}
|
|
572
|
-
for (
|
|
573
|
-
const entry =
|
|
574
|
-
if (entry === undefined) continue // a symlink: its blob is its target string
|
|
594
|
+
for (let i = 0; i < rels.length; i++) {
|
|
595
|
+
const entry = entries[i]!
|
|
575
596
|
const size = sizes.get(entry.oid)
|
|
576
597
|
// The index keeps the low 32 bits of a size.
|
|
577
|
-
if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(
|
|
598
|
+
if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rels[i]!)
|
|
578
599
|
}
|
|
579
600
|
}
|
|
580
601
|
|
|
@@ -1064,7 +1085,7 @@ export async function startGitEnumeration(
|
|
|
1064
1085
|
// (`dropResizedOids`, A-60).
|
|
1065
1086
|
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
|
|
1066
1087
|
(run) =>
|
|
1067
|
-
run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug:
|
|
1088
|
+
run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: splitLsDebug(run) },
|
|
1068
1089
|
)
|
|
1069
1090
|
const running = Promise.all([
|
|
1070
1091
|
listing,
|
|
@@ -1118,13 +1139,14 @@ export async function startGitEnumeration(
|
|
|
1118
1139
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
1119
1140
|
)
|
|
1120
1141
|
}
|
|
1142
|
+
const indexed = new Map<string, { oid: string; size: number }>()
|
|
1121
1143
|
const {
|
|
1122
1144
|
files: tracked,
|
|
1123
1145
|
oids,
|
|
1124
1146
|
flagged,
|
|
1125
1147
|
gitlinks,
|
|
1126
1148
|
undecodable,
|
|
1127
|
-
} = parseLsFilesOutput(debug!.
|
|
1149
|
+
} = parseLsFilesOutput(debug!.records, debug!.undecodable, { sizes: debug!.sizes, indexed })
|
|
1128
1150
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1129
1151
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
1130
1152
|
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
@@ -1202,7 +1224,7 @@ export async function startGitEnumeration(
|
|
|
1202
1224
|
untracked: dirty === null ? null : untracked,
|
|
1203
1225
|
undecodable,
|
|
1204
1226
|
startedAtMs,
|
|
1205
|
-
indexed
|
|
1227
|
+
indexed,
|
|
1206
1228
|
// A missing blob is an answer ("missing"), never a fetch from a partial
|
|
1207
1229
|
// clone's promisor remote.
|
|
1208
1230
|
catFile: (stdin) =>
|
|
@@ -1257,7 +1279,28 @@ export async function applyGitEnumeration(
|
|
|
1257
1279
|
// ~9k files; ~5 ms this way). '/' sorts below most filename chars,
|
|
1258
1280
|
// so the range [prefix, prefix+'\xff…') is contiguous in the sorted
|
|
1259
1281
|
// array; lowerBound on `prefix` and on `prefix + ''` bracket it.
|
|
1260
|
-
|
|
1282
|
+
// Git lists in order; a list that already is skips the sort.
|
|
1283
|
+
let inOrder = true
|
|
1284
|
+
for (let i = 1; i < all.length; i++) {
|
|
1285
|
+
if (all[i - 1]! > all[i]!) {
|
|
1286
|
+
inOrder = false
|
|
1287
|
+
break
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const sorted = inOrder ? all : [...all].sort()
|
|
1291
|
+
// Git prints normalized relative paths, so under an absolute, normalized
|
|
1292
|
+
// root a join is a concatenation: `path.join` per tracked file and
|
|
1293
|
+
// `path.relative` per project were most of this pass (I-30's rule).
|
|
1294
|
+
const base =
|
|
1295
|
+
path.sep === '/' &&
|
|
1296
|
+
path.isAbsolute(workspaceRoot) &&
|
|
1297
|
+
path.normalize(workspaceRoot) === workspaceRoot
|
|
1298
|
+
? workspaceRoot.endsWith('/')
|
|
1299
|
+
? workspaceRoot
|
|
1300
|
+
: `${workspaceRoot}/`
|
|
1301
|
+
: undefined
|
|
1302
|
+
const abs = (rel: string): string =>
|
|
1303
|
+
base === undefined ? path.join(workspaceRoot, rel) : base + rel
|
|
1261
1304
|
const lowerBound = (key: string): number => {
|
|
1262
1305
|
let lo = 0
|
|
1263
1306
|
let hi = sorted.length
|
|
@@ -1269,11 +1312,14 @@ export async function applyGitEnumeration(
|
|
|
1269
1312
|
return lo
|
|
1270
1313
|
}
|
|
1271
1314
|
for (const projectDir of projectDirs) {
|
|
1272
|
-
const relPrefix =
|
|
1315
|
+
const relPrefix =
|
|
1316
|
+
base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
|
|
1317
|
+
? projectDir.slice(base.length).replace(/\/$/, '')
|
|
1318
|
+
: path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
|
|
1273
1319
|
if (relPrefix === '' || relPrefix === '.') {
|
|
1274
1320
|
cache.set(projectDir, all)
|
|
1275
1321
|
const rootOids = new Map<string, string>()
|
|
1276
|
-
for (const [rel, oid] of trusted) rootOids.set(
|
|
1322
|
+
for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
|
|
1277
1323
|
cache.setOids(projectDir, rootOids)
|
|
1278
1324
|
continue
|
|
1279
1325
|
}
|
|
@@ -1286,7 +1332,7 @@ export async function applyGitEnumeration(
|
|
|
1286
1332
|
const rel = sorted[i]!
|
|
1287
1333
|
matches.push(rel.slice(prefix.length))
|
|
1288
1334
|
const oid = trusted.get(rel)
|
|
1289
|
-
if (oid !== undefined) projOids.set(
|
|
1335
|
+
if (oid !== undefined) projOids.set(abs(rel), oid)
|
|
1290
1336
|
}
|
|
1291
1337
|
// An empty slice is a directory git did not see, not an empty project:
|
|
1292
1338
|
// a project has at least its package.json, tracked or untracked. A
|
|
@@ -1308,7 +1354,7 @@ export async function applyGitEnumeration(
|
|
|
1308
1354
|
}
|
|
1309
1355
|
if (workspaceWide) {
|
|
1310
1356
|
const rootOids = new Map<string, string>()
|
|
1311
|
-
for (const [rel, oid] of trusted) rootOids.set(
|
|
1357
|
+
for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
|
|
1312
1358
|
cache.set(workspaceRoot, all)
|
|
1313
1359
|
cache.setOids(workspaceRoot, rootOids)
|
|
1314
1360
|
cache.setWorkspaceRoot(workspaceRoot)
|
package/src/cache/inputs.ts
CHANGED
|
@@ -385,6 +385,7 @@ async function runRuntimeCommand(
|
|
|
385
385
|
command: string,
|
|
386
386
|
cwd: string,
|
|
387
387
|
binDirs: readonly string[],
|
|
388
|
+
owner: RuntimeMemo | undefined,
|
|
388
389
|
): Promise<string> {
|
|
389
390
|
const ambient = process.env['PATH']
|
|
390
391
|
const prefix = binDirs.join(path.delimiter)
|
|
@@ -416,6 +417,12 @@ async function runRuntimeCommand(
|
|
|
416
417
|
throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
|
|
417
418
|
}
|
|
418
419
|
liveProbes.add(proc)
|
|
420
|
+
let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
|
|
421
|
+
if (owner !== undefined) {
|
|
422
|
+
mine = probesOf.get(owner) ?? new Set()
|
|
423
|
+
probesOf.set(owner, mine)
|
|
424
|
+
mine.add(proc)
|
|
425
|
+
}
|
|
419
426
|
killProbesOnExit()
|
|
420
427
|
let stdout, stderr, exitCode
|
|
421
428
|
try {
|
|
@@ -426,6 +433,7 @@ async function runRuntimeCommand(
|
|
|
426
433
|
])
|
|
427
434
|
} finally {
|
|
428
435
|
liveProbes.delete(proc)
|
|
436
|
+
mine?.delete(proc)
|
|
429
437
|
}
|
|
430
438
|
const output = `${stdout}${stderr}`.trim()
|
|
431
439
|
if (exitCode !== 0) {
|
|
@@ -449,18 +457,26 @@ async function runRuntimeCommand(
|
|
|
449
457
|
const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
|
|
450
458
|
let probeExitHooked = false
|
|
451
459
|
|
|
460
|
+
type RuntimeMemo = Map<string, Promise<string>>
|
|
461
|
+
/** The probes each run started, by the run's memo: one run's stop is not another's. */
|
|
462
|
+
const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
|
|
463
|
+
|
|
452
464
|
/**
|
|
453
|
-
* Kill every runtime probe still running
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
465
|
+
* Kill every runtime probe still running that these memos (one run's
|
|
466
|
+
* `runtimeCache` and `workspaceRuntimeCache`) started, with its tree. A
|
|
467
|
+
* run's stop asks this: a Ctrl-C while a probe ran waited for the probe,
|
|
468
|
+
* or for the signal handler's bound, about 7 s, before vx exited (C-65).
|
|
469
|
+
* Its answer is no longer needed; its caller sees it fail and the run
|
|
470
|
+
* reads it aborted. Another run in the process keeps its own.
|
|
457
471
|
*/
|
|
458
|
-
export function stopRuntimeProbes(): void {
|
|
459
|
-
for (const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
472
|
+
export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
|
|
473
|
+
for (const memo of memos) {
|
|
474
|
+
for (const p of probesOf.get(memo) ?? []) {
|
|
475
|
+
try {
|
|
476
|
+
process.kill(-p.pid, 'SIGKILL')
|
|
477
|
+
} catch {
|
|
478
|
+
// the group is gone
|
|
479
|
+
}
|
|
464
480
|
}
|
|
465
481
|
}
|
|
466
482
|
}
|
|
@@ -501,7 +517,7 @@ async function resolveRuntimeValues(
|
|
|
501
517
|
const key = `${memoKeyPrefix}${cmd}`
|
|
502
518
|
let p = memo?.get(key)
|
|
503
519
|
if (p === undefined) {
|
|
504
|
-
p = runRuntimeCommand(cmd, cwd, binDirs)
|
|
520
|
+
p = runRuntimeCommand(cmd, cwd, binDirs, memo)
|
|
505
521
|
memo?.set(key, p)
|
|
506
522
|
}
|
|
507
523
|
return [cmd, await p] as [string, string]
|
package/src/cli/cache.ts
CHANGED
|
@@ -45,7 +45,10 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
|
|
|
45
45
|
case 'prune':
|
|
46
46
|
return await pruneCmd(rest)
|
|
47
47
|
case undefined:
|
|
48
|
-
|
|
48
|
+
// A bare `vx cache prune` refuses too: the hint names a whole command.
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`vx cache: missing subcommand. Try \`vx cache prune --older-than 30d\` or \`vx cache prune --max-size 1G\`${seeHelp('cache')}\n`,
|
|
51
|
+
)
|
|
49
52
|
return 1
|
|
50
53
|
default:
|
|
51
54
|
process.stderr.write(
|
package/src/cli/show.ts
CHANGED
|
@@ -66,10 +66,17 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
66
66
|
const metas = await discoverCliProjects(await loadWorkspace(root, reads))
|
|
67
67
|
const byName = new Map(metas.map((m) => [m.name, m]))
|
|
68
68
|
|
|
69
|
-
// `//#task` is Turbo's root package's task: the root project here (D-39)
|
|
69
|
+
// `//#task` is Turbo's root package's task: the root project here (D-39),
|
|
70
|
+
// and `//` alone is that project, as `--filter //` reads it (D-46).
|
|
70
71
|
const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
|
|
71
|
-
if (parsed.target?.startsWith('//#')
|
|
72
|
-
|
|
72
|
+
if (parsed.target === '//' || parsed.target?.startsWith('//#')) {
|
|
73
|
+
if (rootMeta === undefined) {
|
|
74
|
+
throw new UserError(
|
|
75
|
+
`vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
|
|
79
|
+
}
|
|
73
80
|
const hashAt = parsed.target?.indexOf('#') ?? -1
|
|
74
81
|
const projectName =
|
|
75
82
|
parsed.target === undefined
|
|
@@ -87,7 +94,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
87
94
|
if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
|
|
88
95
|
if (!byName.has(projectName)) {
|
|
89
96
|
throw new UserError(
|
|
90
|
-
`unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
|
|
97
|
+
`vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
|
|
91
98
|
)
|
|
92
99
|
}
|
|
93
100
|
}
|
|
@@ -114,7 +121,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
114
121
|
// `nx show projects` lists them; here a bare `vx show` does.
|
|
115
122
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
116
123
|
throw new UserError(
|
|
117
|
-
`unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names])}`,
|
|
124
|
+
`vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names])}`,
|
|
118
125
|
)
|
|
119
126
|
}
|
|
120
127
|
process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
|
|
@@ -136,7 +143,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
136
143
|
const task = declaredTask(config, taskName)
|
|
137
144
|
if (task === undefined) {
|
|
138
145
|
throw new UserError(
|
|
139
|
-
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
|
|
146
|
+
`vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
|
|
140
147
|
)
|
|
141
148
|
}
|
|
142
149
|
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
package/src/cli/why.ts
CHANGED
|
@@ -44,7 +44,8 @@ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
|
|
|
44
44
|
package: "the project's package.json changed (a dependency, version or script)",
|
|
45
45
|
workspace:
|
|
46
46
|
'a lockfile or root manifest changed, which re-keys every task; a lockfile plugin keys each project on its own dependencies',
|
|
47
|
-
config:
|
|
47
|
+
config:
|
|
48
|
+
"the task's evaluated config changed (its vx.config or a file it imports); `vx show <that task>` prints it as it is now",
|
|
48
49
|
upstream: "a dependency's key moved; `vx why <that task>` says why",
|
|
49
50
|
plugin: "a key plugin's material changed; that plugin decides what it folds",
|
|
50
51
|
}
|
|
@@ -267,7 +268,9 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
267
268
|
'`vx why <that task>`',
|
|
268
269
|
upstream.map((n) => `\`vx why ${n}\``).join(', '),
|
|
269
270
|
)
|
|
270
|
-
:
|
|
271
|
+
: k === 'config'
|
|
272
|
+
? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
|
|
273
|
+
: WHAT_TO_DO[k]
|
|
271
274
|
lines.push(` ${k.padEnd(kindW)} ${todo}`)
|
|
272
275
|
}
|
|
273
276
|
}
|
package/src/exec/env.ts
CHANGED
|
@@ -71,6 +71,7 @@ export interface BuildEnvOptions {
|
|
|
71
71
|
* (`oxlint`, `tsc`, `vitest`, …) resolve without a PM wrapper. Matches
|
|
72
72
|
* vite-task's behavior — explicit per-project bin, no tree walk, so
|
|
73
73
|
* sibling projects' bins stay invisible per the project-isolation rule.
|
|
74
|
+
* One holding `path.delimiter` is left out: PATH cannot name it.
|
|
74
75
|
*/
|
|
75
76
|
binPaths?: readonly string[]
|
|
76
77
|
}
|
|
@@ -90,8 +91,12 @@ export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
|
|
|
90
91
|
out[name] = value
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// A directory holding the delimiter cannot be named in PATH: split, it
|
|
95
|
+
// became two entries naming nothing, the second RELATIVE, so resolved
|
|
96
|
+
// against the task's cwd. The 127 verdict says why it is missing.
|
|
97
|
+
const bins = opts.binPaths?.filter((dir) => !dir.includes(path.delimiter)) ?? []
|
|
98
|
+
if (bins.length > 0) {
|
|
99
|
+
const prefix = bins.join(path.delimiter)
|
|
95
100
|
out['PATH'] = out['PATH'] ? `${prefix}${path.delimiter}${out['PATH']}` : prefix
|
|
96
101
|
}
|
|
97
102
|
|
|
@@ -36,7 +36,7 @@ export function bindableWrites(paths: readonly string[]): string[] {
|
|
|
36
36
|
if (process.platform !== 'linux') return [...paths]
|
|
37
37
|
return unique(
|
|
38
38
|
paths
|
|
39
|
-
.filter((p) => !
|
|
39
|
+
.filter((p) => !holdsGlobChar(p))
|
|
40
40
|
.map((p) => {
|
|
41
41
|
if (!isMountableLiteral(p)) return p
|
|
42
42
|
try {
|
|
@@ -191,31 +191,66 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/** Grants already reported — once per process, not per spawn. */
|
|
194
|
-
const
|
|
194
|
+
const warnedGlob = new Set<string>()
|
|
195
195
|
|
|
196
196
|
/**
|
|
197
|
-
* SRT drops every Linux write path holding
|
|
198
|
-
* glob, and a write path must be a path)
|
|
199
|
-
*
|
|
200
|
-
*
|
|
197
|
+
* SRT drops every Linux write path holding `[`, `]`, `*` or `?` (it reads
|
|
198
|
+
* one as a glob, and a write path must be a path) or a backslash (Bun's
|
|
199
|
+
* realpath refuses it; `bindableReads`), and no spelling keeps
|
|
200
|
+
* it. Left in, the read grants were punched around a bind that never came,
|
|
201
|
+
* the directory vanished from the task's view, its write read "Directory
|
|
201
202
|
* nonexistent" with no word of the grant, and the refused write went
|
|
202
203
|
* unreported, judged against it. Dropped from the binds instead, and said
|
|
203
204
|
* once: the remedy is the deepest directory above it whose name holds none.
|
|
204
205
|
*/
|
|
205
|
-
function
|
|
206
|
-
const at = grant.search(/[[\]]/)
|
|
206
|
+
function holdsGlobChar(grant: string): boolean {
|
|
207
|
+
const at = srtStripped(grant).search(/[[\]*?\\]/)
|
|
207
208
|
if (at === -1) return false
|
|
208
|
-
if (!
|
|
209
|
-
|
|
209
|
+
if (!warnedGlob.has(grant)) {
|
|
210
|
+
warnedGlob.add(grant)
|
|
210
211
|
process.stderr.write(
|
|
211
|
-
`[vx] sandbox: the write grant ${grant} holds
|
|
212
|
-
`write path that does, so a write under it is refused. Grant the
|
|
213
|
-
`instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
|
|
212
|
+
`[vx] sandbox: the write grant ${grant} holds ${globChar(grant[at]!)}, and the Linux ` +
|
|
213
|
+
`sandbox mounts no write path that does, so a write under it is refused. Grant the ` +
|
|
214
|
+
`directory above it instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
|
|
214
215
|
)
|
|
215
216
|
}
|
|
216
217
|
return true
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/** A trailing `/**` SRT strips before it asks whether a path is a glob. */
|
|
221
|
+
const srtStripped = (grant: string): string => grant.replace(/\/\*\*$/, '')
|
|
222
|
+
|
|
223
|
+
const globChar = (c: string): string =>
|
|
224
|
+
c === '[' || c === ']' ? 'a bracket' : c === '\\' ? 'a backslash' : `a ${c}`
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Linux: the read grants SRT can mount as the paths they are. It reads
|
|
228
|
+
* one holding `*` or `?` as a glob and mounts every match, and no spelling
|
|
229
|
+
* makes either literal (its rewrite of each runs inside a class too), so a
|
|
230
|
+
* grant of `a*b.txt` granted `aXb.txt`. One holding a backslash it skips:
|
|
231
|
+
* Bun's `realpathSync` throws ENOENT on such a path (Node's does not), and
|
|
232
|
+
* SRT mounts no path it cannot resolve. Left out, so a read of it is
|
|
233
|
+
* refused and reported, and said once; a bracket has a spelling
|
|
234
|
+
* (`literalReadPaths`).
|
|
235
|
+
*/
|
|
236
|
+
export function bindableReads(paths: readonly string[]): string[] {
|
|
237
|
+
if (process.platform !== 'linux') return [...paths]
|
|
238
|
+
return paths.filter((grant) => {
|
|
239
|
+
const at = srtStripped(grant).search(/[*?\\]/)
|
|
240
|
+
if (at === -1) return true
|
|
241
|
+
if (!warnedGlob.has(grant)) {
|
|
242
|
+
warnedGlob.add(grant)
|
|
243
|
+
process.stderr.write(
|
|
244
|
+
`[vx] sandbox: the read grant ${grant} holds ${globChar(grant[at]!)}, which the Linux ` +
|
|
245
|
+
`sandbox ${grant[at] === '\\' ? 'cannot resolve' : 'reads as a pattern that also matches its siblings'}, so it is not granted. ` +
|
|
246
|
+
`Rename it, or grant the directory above it: ` +
|
|
247
|
+
`${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
return false
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
/**
|
|
220
255
|
* Merge the orchestrator-provided baseline (declared inputs / outputs /
|
|
221
256
|
* workspace-root anchor) with the user's resolved sandbox block to
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
UserError,
|
|
64
64
|
xxh3hex,
|
|
65
65
|
} from '../util/index.js'
|
|
66
|
-
import { bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
|
|
66
|
+
import { bindableReads, bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
|
|
67
67
|
import {
|
|
68
68
|
atOrUnder,
|
|
69
69
|
isMountableLiteral,
|
|
@@ -1167,7 +1167,8 @@ function asksUnixSockets(c: Pick<ResolvedSandboxConfig, 'unixSockets'>): boolean
|
|
|
1167
1167
|
* but SRT reads any holding `[` as a glob, where a bracket opens a class:
|
|
1168
1168
|
* a route granted as `pages/\[id\].tsx` was never mounted (its denial
|
|
1169
1169
|
* unreported, a listed grant), and a workspace under `[ws]/` was never
|
|
1170
|
-
* walled. `[[]` is a class of one `[`; a lone `]` is plain text to it.
|
|
1170
|
+
* walled. `[[]` is a class of one `[`; a lone `]` is plain text to it. A
|
|
1171
|
+
* `*` or `?` has no such spelling: `bindableReads` leaves its grant out.
|
|
1171
1172
|
*/
|
|
1172
1173
|
function literalReadPaths(
|
|
1173
1174
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
@@ -1179,8 +1180,9 @@ function literalReadPaths(
|
|
|
1179
1180
|
...config,
|
|
1180
1181
|
filesystem: {
|
|
1181
1182
|
...fs,
|
|
1183
|
+
// A `*` or `?` in a deny path matches its siblings too: a wider wall.
|
|
1182
1184
|
denyRead: escape(fs.denyRead),
|
|
1183
|
-
...(fs.allowRead !== undefined ? { allowRead: escape(fs.allowRead) } : {}),
|
|
1185
|
+
...(fs.allowRead !== undefined ? { allowRead: escape(bindableReads(fs.allowRead)) } : {}),
|
|
1184
1186
|
},
|
|
1185
1187
|
}
|
|
1186
1188
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
9
|
+
import { bindableReads } from './sandbox-binds.js'
|
|
9
10
|
import type {
|
|
10
11
|
ResolvedSandboxConfig,
|
|
11
12
|
SandboxedRunArgs,
|
|
@@ -274,7 +275,10 @@ export async function parseStraceViolations(
|
|
|
274
275
|
// real paths (see `canonicalBaselines`), so comparing a link-path here
|
|
275
276
|
// would report an explicitly-allowed read as a violation.
|
|
276
277
|
const allowAbs = new Set<string>(
|
|
277
|
-
|
|
278
|
+
// A grant SRT could not mount (`bindableReads`) permits nothing.
|
|
279
|
+
[...baselines.allowRead, ...bindableReads(args.config.allowRead)].map((p) =>
|
|
280
|
+
toRealPath(absolutize(p)),
|
|
281
|
+
),
|
|
278
282
|
)
|
|
279
283
|
const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
|
|
280
284
|
|
|
@@ -347,17 +347,15 @@ async function countLoadableTasks(
|
|
|
347
347
|
} catch (err) {
|
|
348
348
|
// Counted as zero, and named: the loader's message opens with the
|
|
349
349
|
// absolute path — bare for a schema refusal, behind `Project config`
|
|
350
|
-
// for an import that failed
|
|
351
|
-
//
|
|
352
|
-
// twice, 2026-09-16
|
|
350
|
+
// for an import that failed, and with `:line:col` after it for a
|
|
351
|
+
// syntax error — which the row carries workspace-relative instead,
|
|
352
|
+
// once (the second form named the file twice, 2026-09-16; the third
|
|
353
|
+
// still did).
|
|
353
354
|
const raw = err instanceof Error ? err.message : String(err)
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
break
|
|
359
|
-
}
|
|
360
|
-
}
|
|
355
|
+
const bare = raw.startsWith('Project config ') ? raw.slice('Project config '.length) : raw
|
|
356
|
+
const message = bare.startsWith(`${meta.configPath}:`)
|
|
357
|
+
? bare.slice(meta.configPath.length + 1).trimStart()
|
|
358
|
+
: raw
|
|
361
359
|
errors.push({
|
|
362
360
|
path: path.relative(root, meta.configPath).split(path.sep).join('/'),
|
|
363
361
|
message,
|
|
@@ -449,8 +449,12 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
|
|
|
449
449
|
*/
|
|
450
450
|
export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
|
|
451
451
|
if (!(err instanceof Error) || !executorPlugin.has(executor)) return err
|
|
452
|
+
// Once: one error an executor rejects several tasks with (a failed
|
|
453
|
+
// connection it memoized) reaches here once per task (C-74).
|
|
454
|
+
const prefix = `${executorLabel(executor)} failed in execute: `
|
|
455
|
+
if (err.message.startsWith(prefix)) return err
|
|
452
456
|
try {
|
|
453
|
-
err.message = `${
|
|
457
|
+
err.message = `${prefix}${err.message}`
|
|
454
458
|
} catch {
|
|
455
459
|
// A frozen error keeps its own words.
|
|
456
460
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -538,7 +538,7 @@ async function runOnBus(
|
|
|
538
538
|
// ends after it is the stop's own kill, and was named a crash (item 1061).
|
|
539
539
|
let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
|
|
540
540
|
const onAbort = (): void => {
|
|
541
|
-
stopRuntimeProbes()
|
|
541
|
+
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
542
542
|
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
543
543
|
aborting = terminateChildren(
|
|
544
544
|
() => [...liveChildren, ...persistentRegistry.values()],
|
|
@@ -670,6 +670,25 @@ async function runOnBus(
|
|
|
670
670
|
}
|
|
671
671
|
|
|
672
672
|
const sandboxArmer = prepareSandbox(nodes.values())
|
|
673
|
+
// The probe is ~220 ms of spawns, paid by the first sandboxed task to
|
|
674
|
+
// execute. A task no cache can answer (no `cache`, reads off, or
|
|
675
|
+
// persistent) is sure to: start it now, under the classify and the
|
|
676
|
+
// upstream work. The task's own `arm()` rethrows a refusal, and the
|
|
677
|
+
// end of the run waits for it: one that landed after the reset left
|
|
678
|
+
// SRT's proxies holding the process open.
|
|
679
|
+
const surelyRuns = (n: TaskNode): boolean =>
|
|
680
|
+
n.config.exec?.persistent !== undefined ||
|
|
681
|
+
n.config.cache === undefined ||
|
|
682
|
+
!(policy.localRead || policy.remoteRead)
|
|
683
|
+
const sandboxed =
|
|
684
|
+
sandboxArmer === null
|
|
685
|
+
? []
|
|
686
|
+
: [...nodes.values()].filter((n) => n.config.exec?.sandbox !== undefined)
|
|
687
|
+
let prewarming: Promise<void> | undefined
|
|
688
|
+
const prewarm = (): void => {
|
|
689
|
+
prewarming ??= sandboxArmer?.arm().catch(() => {})
|
|
690
|
+
}
|
|
691
|
+
if (sandboxed.some(surelyRuns)) prewarm()
|
|
673
692
|
const keyed = keyedProjects(nodes, prepared.keyOnly)
|
|
674
693
|
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
675
694
|
// Saves run off the execution slot, twice the cap at once (memory:
|
|
@@ -771,6 +790,8 @@ async function runOnBus(
|
|
|
771
790
|
hashCache,
|
|
772
791
|
concurrency,
|
|
773
792
|
})
|
|
793
|
+
// A confirmed miss runs too.
|
|
794
|
+
if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
|
|
774
795
|
}
|
|
775
796
|
|
|
776
797
|
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
@@ -1127,6 +1148,7 @@ async function runOnBus(
|
|
|
1127
1148
|
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
1128
1149
|
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
1129
1150
|
// the next vx run would init on top of stale state.
|
|
1151
|
+
await prewarming
|
|
1130
1152
|
if (sandboxArmer?.armed) {
|
|
1131
1153
|
try {
|
|
1132
1154
|
await resetSandbox()
|
|
@@ -104,7 +104,8 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
|
|
|
104
104
|
* Linux, the runtime module's own load included) even when every task was
|
|
105
105
|
* a cache hit and nothing executed; measured 2026-09-10 on this repo's
|
|
106
106
|
* own warm gate: `classify + probe` 288 ms of a 798 ms run. A hit needs
|
|
107
|
-
* no sandbox, so a hit pays nothing.
|
|
107
|
+
* no sandbox, so a hit pays nothing. `run()` arms early only when a
|
|
108
|
+
* sandboxed task is sure to execute (C-76).
|
|
108
109
|
*
|
|
109
110
|
* The domain union is computed here from every sandboxed node, because
|
|
110
111
|
* SRT runs ONE filtering proxy per run and checks every request against
|
|
@@ -190,13 +191,23 @@ export async function sandboxRequestFor(
|
|
|
190
191
|
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
191
192
|
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
192
193
|
// character class, so vx's own workspace wall matched nothing (B-65).
|
|
194
|
+
// A `*` or `?` is one too, and its grants matched the project's siblings.
|
|
193
195
|
const home = toRealPath(node.projectDir)
|
|
194
|
-
if (process.platform !== 'win32' && /[[\]]/.test(home)) {
|
|
196
|
+
if (process.platform !== 'win32' && /[[\]*?]/.test(home)) {
|
|
195
197
|
throw new UserError(
|
|
196
|
-
`exec.sandbox: ${home} holds
|
|
198
|
+
`exec.sandbox: ${home} holds [, ], * or ?, which the sandbox runtime reads as a ` +
|
|
197
199
|
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
|
198
200
|
)
|
|
199
201
|
}
|
|
202
|
+
// Bun's realpath throws ENOENT on a path holding a backslash, and SRT
|
|
203
|
+
// mounts no path it cannot resolve: the task saw no project and ran in
|
|
204
|
+
// $HOME.
|
|
205
|
+
if (process.platform === 'linux' && home.includes('\\')) {
|
|
206
|
+
throw new UserError(
|
|
207
|
+
`exec.sandbox: ${home} holds a backslash, which the Linux sandbox cannot resolve, so ` +
|
|
208
|
+
`it mounts none of the project — rename the directory, or run the task without exec.sandbox`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
200
211
|
const depDirs = [
|
|
201
212
|
path.join(node.projectDir, 'node_modules'),
|
|
202
213
|
path.join(workspaceRoot, 'node_modules'),
|
|
@@ -43,6 +43,10 @@ export function shellVerdict(args: {
|
|
|
43
43
|
if (args.code === 126) {
|
|
44
44
|
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
45
45
|
}
|
|
46
|
+
const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
|
|
47
|
+
if (unnamed !== undefined) {
|
|
48
|
+
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
|
|
49
|
+
}
|
|
46
50
|
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -272,7 +272,8 @@ export function formatSummarySection(
|
|
|
272
272
|
/**
|
|
273
273
|
* `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
|
|
274
274
|
* with the failures first when there are any. Cached is every hit (up to
|
|
275
|
-
* date, restored locally or remotely) over every task that
|
|
275
|
+
* date, restored locally or remotely) over every task that consulted a
|
|
276
|
+
* cache: a skipped or not-yet-run task never did.
|
|
276
277
|
*/
|
|
277
278
|
function resultLine(
|
|
278
279
|
stats: SummaryStats,
|
|
@@ -283,9 +284,11 @@ function resultLine(
|
|
|
283
284
|
const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
|
|
284
285
|
if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
|
|
285
286
|
// A task with no `cache` block could never hit: it is counted apart, not
|
|
286
|
-
// as a miss, or `vx run dev` would read "0 cached (0%)".
|
|
287
|
+
// as a miss, or `vx run dev` would read "0 cached (0%)". A skipped task
|
|
288
|
+
// never asked either, and counted as a miss it put "0 cached (0%)" on a
|
|
289
|
+
// run in which no task has a cache.
|
|
287
290
|
const noCache = stats.noCache ?? 0
|
|
288
|
-
const cacheable = stats.total - noCache
|
|
291
|
+
const cacheable = stats.total - noCache - stats.skipped - (stats.left ?? 0)
|
|
289
292
|
if (cacheable > 0)
|
|
290
293
|
parts.push(
|
|
291
294
|
hits === cacheable
|
|
@@ -754,7 +754,22 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
754
754
|
'one task per configuration (`build:production`, with its own `exec.command`)',
|
|
755
755
|
},
|
|
756
756
|
],
|
|
757
|
-
[
|
|
757
|
+
[
|
|
758
|
+
EXEC_FIELDS,
|
|
759
|
+
{
|
|
760
|
+
cmd: '`command`',
|
|
761
|
+
script: '`command`',
|
|
762
|
+
// Nx run-commands' options and shell-runner habits, refused with no
|
|
763
|
+
// word on where they go (D-100).
|
|
764
|
+
cwd: '`cd <dir> && …` in `command`: a task runs in its project directory',
|
|
765
|
+
args: 'the arguments written into `command`, or after `--` on `vx run`',
|
|
766
|
+
commands: 'one `command` (`a && b`, or `a & b; wait` to run them at once), or one task each',
|
|
767
|
+
parallel: 'one task each, which vx runs at once, or `a & b; wait` in `command`',
|
|
768
|
+
shell: 'no field: `command` always runs in a shell',
|
|
769
|
+
interactive:
|
|
770
|
+
'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
|
|
771
|
+
},
|
|
772
|
+
],
|
|
758
773
|
// A package's turbo.json and Nx's project.json keys, written into a
|
|
759
774
|
// project's vx.config (D-56). `tags` had read as a typo of `tasks`.
|
|
760
775
|
[
|
|
@@ -806,6 +821,24 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
806
821
|
defaultBase: '`affectedBase`',
|
|
807
822
|
tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
|
|
808
823
|
globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
|
|
824
|
+
// pnpm-workspace.yaml's, Lerna's and the package.json's own keys
|
|
825
|
+
// pasted into vx.workspace: refused with no word on where vx reads
|
|
826
|
+
// them (D-98).
|
|
827
|
+
packages:
|
|
828
|
+
"`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
|
|
829
|
+
workspaces:
|
|
830
|
+
"`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
|
|
831
|
+
projects:
|
|
832
|
+
"`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
|
|
833
|
+
ignore:
|
|
834
|
+
'a `!` pattern in the workspace globs (package.json `workspaces` or pnpm-workspace.yaml)',
|
|
835
|
+
catalog:
|
|
836
|
+
"pnpm-workspace.yaml's or package.json's `catalog`, where the package manager reads it",
|
|
837
|
+
npmClient: "no field: a task's command runs as written, with no package manager in between",
|
|
838
|
+
cache:
|
|
839
|
+
"each task's `cache` in its project's vx.config (a task caches only when it declares one)",
|
|
840
|
+
remote: 'a cache plugin in `plugins`',
|
|
841
|
+
env: '`cache.inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
|
|
809
842
|
},
|
|
810
843
|
],
|
|
811
844
|
])
|
|
@@ -343,6 +343,48 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
343
343
|
return false
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/** The workspace flags that move a package manager off the package it runs in. */
|
|
347
|
+
const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
|
|
348
|
+
const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The part of a member's script that runs another member's work: pinia's
|
|
352
|
+
* online-playground `build` is `pnpm -C ../pinia run build && vite build`,
|
|
353
|
+
* and mapped verbatim it built pinia again beside pinia's own task, writing
|
|
354
|
+
* the dist its siblings read. `yarn workspace <name>`, a runner, `pnpm -r`
|
|
355
|
+
* / `--filter`, and a `cd` / `-C` / `--cwd` / `--prefix` whose innermost
|
|
356
|
+
* member is another one count; a fixture inside the member, or the root,
|
|
357
|
+
* does not.
|
|
358
|
+
*/
|
|
359
|
+
function siblingRun(script: string, dir: string, others: readonly string[]): string | undefined {
|
|
360
|
+
const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
|
|
361
|
+
const intoOther = (target: string): boolean => {
|
|
362
|
+
const t = path.resolve(dir, target.replace(/^(["'])(.*)\1$/, '$2'))
|
|
363
|
+
if (/[$`~*?]/.test(target)) return false
|
|
364
|
+
const owner = [dir, ...others]
|
|
365
|
+
.filter((d) => inside(t, d))
|
|
366
|
+
.sort((x, y) => y.length - x.length)[0]
|
|
367
|
+
return owner !== undefined && owner !== dir
|
|
368
|
+
}
|
|
369
|
+
for (const segment of script.split(/&&|\|\||[;|()]/)) {
|
|
370
|
+
const s = segment.trim()
|
|
371
|
+
if (RUNS_MEMBERS.test(` ${s}`)) return s
|
|
372
|
+
for (const m of s.matchAll(CD)) if (intoOther(m[1]!)) return s
|
|
373
|
+
const words = s.split(/\s+/)
|
|
374
|
+
const at = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
|
|
375
|
+
if (at < 0) continue
|
|
376
|
+
for (let i = at + 1; i < words.length; i++) {
|
|
377
|
+
const w = words[i]!
|
|
378
|
+
if (ELSEWHERE_FLAG.test(w)) return s
|
|
379
|
+
const flag = DIR_FLAG.exec(w)
|
|
380
|
+
if (flag === null) continue
|
|
381
|
+
const target = flag[2] ?? words[++i]
|
|
382
|
+
if (target !== undefined && intoOther(target)) return s
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return undefined
|
|
386
|
+
}
|
|
387
|
+
|
|
346
388
|
/** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
|
|
347
389
|
const RUNS_SCRIPT =
|
|
348
390
|
/(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
@@ -589,6 +631,19 @@ export function migrateScripts(
|
|
|
589
631
|
`${hooksBy} ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
590
632
|
)
|
|
591
633
|
}
|
|
634
|
+
const sibling =
|
|
635
|
+
meta === rootMeta
|
|
636
|
+
? undefined
|
|
637
|
+
: siblingRun(
|
|
638
|
+
own,
|
|
639
|
+
meta.dir,
|
|
640
|
+
metas.filter((m) => m !== root && m !== meta).map((m) => m.dir),
|
|
641
|
+
)
|
|
642
|
+
if (sibling !== undefined) {
|
|
643
|
+
todos.push(
|
|
644
|
+
`\`${sibling}\` runs another member's work outside the graph, again beside that member's own task: name that task under dependsOn (\`<member>#<task>\`) and drop it from the command`,
|
|
645
|
+
)
|
|
646
|
+
}
|
|
592
647
|
const exec: Record<string, unknown> = { command }
|
|
593
648
|
const npm = npmEnv(command, name, hooks.length > 0)
|
|
594
649
|
if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
|
|
@@ -541,13 +541,38 @@ const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = [
|
|
|
541
541
|
['Promise.prototype', Promise.prototype],
|
|
542
542
|
]
|
|
543
543
|
|
|
544
|
-
|
|
544
|
+
interface OwnProperties {
|
|
545
|
+
keys: PropertyKey[]
|
|
546
|
+
descriptors: PropertyDescriptor[]
|
|
547
|
+
byKey: ReadonlyMap<PropertyKey, PropertyDescriptor>
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
type BuiltinSnapshot = readonly OwnProperties[]
|
|
545
551
|
|
|
546
552
|
function builtinSnapshot(): BuiltinSnapshot {
|
|
547
|
-
return WATCHED_BUILTINS.map(
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
|
|
553
|
+
return WATCHED_BUILTINS.map(([, proto]) => {
|
|
554
|
+
const keys = Reflect.ownKeys(proto)
|
|
555
|
+
const descriptors = keys.map((k) => Object.getOwnPropertyDescriptor(proto, k)!)
|
|
556
|
+
return { keys, descriptors, byKey: new Map(keys.map((k, j) => [k, descriptors[j]!])) }
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The same keys in the same order with the same descriptors: nothing to
|
|
562
|
+
* put back. Read by position, without the by-key lookups and the second
|
|
563
|
+
* pass for deleted keys, it is the cold path's common case once per
|
|
564
|
+
* evaluated config: the full check was ~0.1 ms a config, ~100 ms of a
|
|
565
|
+
* 1,000-config cold load.
|
|
566
|
+
*/
|
|
567
|
+
function unchanged(proto: object, keys: readonly PropertyKey[], was: OwnProperties): boolean {
|
|
568
|
+
if (keys.length !== was.keys.length) return false
|
|
569
|
+
for (let j = 0; j < keys.length; j++) {
|
|
570
|
+
const key = keys[j]!
|
|
571
|
+
if (key !== was.keys[j]) return false
|
|
572
|
+
if (!sameDescriptor(was.descriptors[j]!, Object.getOwnPropertyDescriptor(proto, key)!))
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
return true
|
|
551
576
|
}
|
|
552
577
|
|
|
553
578
|
/** Loads run together, so the config named is the one whose load saw the change. */
|
|
@@ -565,8 +590,10 @@ function builtinsChanged(changed: readonly string[], configPath?: string): UserE
|
|
|
565
590
|
function restoreBuiltins(before: BuiltinSnapshot): string[] {
|
|
566
591
|
const changed: string[] = []
|
|
567
592
|
WATCHED_BUILTINS.forEach(([name, proto], i) => {
|
|
568
|
-
const
|
|
569
|
-
|
|
593
|
+
const keys = Reflect.ownKeys(proto)
|
|
594
|
+
if (unchanged(proto, keys, before[i]!)) return
|
|
595
|
+
const was = before[i]!.byKey
|
|
596
|
+
for (const key of keys) {
|
|
570
597
|
const prior = was.get(key)
|
|
571
598
|
const now = Object.getOwnPropertyDescriptor(proto, key)!
|
|
572
599
|
if (prior !== undefined && sameDescriptor(prior, now)) continue
|