@vzn/vx 0.0.359 → 0.0.361
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 +76 -2
- package/src/cache/inputs.ts +22 -3
- package/src/exec/runner.ts +16 -8
- package/src/exec/sandbox-runtime.ts +60 -6
- package/src/exec/sandbox-violations.ts +10 -4
- package/src/orchestrator/logger.ts +3 -2
- package/src/orchestrator/options.ts +3 -3
- package/src/orchestrator/plugin-host.ts +13 -15
- package/src/orchestrator/run-context.ts +11 -1
- package/src/orchestrator/run.ts +63 -11
- package/src/workspace/migrate-scripts.ts +164 -2
- package/src/workspace/package-graph.ts +40 -2
- package/src/workspace/project-loader.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.361",
|
|
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.361",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.361",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.361",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.361"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// task declared. Nothing here reads a config or applies a boundary.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
|
|
9
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
10
10
|
import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
|
|
11
11
|
|
|
12
|
-
/** Three facts of the repository a directory is in,
|
|
12
|
+
/** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
|
|
13
13
|
export interface RepoFacts {
|
|
14
14
|
/** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
|
|
15
15
|
prefix: string
|
|
@@ -32,6 +32,11 @@ const repoFactsMemo = new Map<string, RepoFacts>()
|
|
|
32
32
|
export function repoFacts(dir: string): RepoFacts | null {
|
|
33
33
|
const hit = repoFactsMemo.get(dir)
|
|
34
34
|
if (hit !== undefined) return hit
|
|
35
|
+
const read = repoFactsFromDisk(dir)
|
|
36
|
+
if (read !== undefined) {
|
|
37
|
+
repoFactsMemo.set(dir, read)
|
|
38
|
+
return read
|
|
39
|
+
}
|
|
35
40
|
let proc
|
|
36
41
|
try {
|
|
37
42
|
proc = Bun.spawnSync({
|
|
@@ -66,6 +71,75 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
66
71
|
return facts
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/** Variables that move where git finds the repository: with any set, git is asked. */
|
|
75
|
+
const GIT_LOCATION_ENV = [
|
|
76
|
+
'GIT_DIR',
|
|
77
|
+
'GIT_WORK_TREE',
|
|
78
|
+
'GIT_COMMON_DIR',
|
|
79
|
+
'GIT_CEILING_DIRECTORIES',
|
|
80
|
+
'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `repoFacts` for the plain case, read off the disk: the nearest `.git`
|
|
85
|
+
* DIRECTORY above `dir` (resolved as git resolves its cwd), on the same
|
|
86
|
+
* file system, holding a `HEAD`, and its config's object format. The
|
|
87
|
+
* spawn it saves was ~3 ms of every run, synchronous. Undefined — ask
|
|
88
|
+
* git — for anything else: a `.git` file (a linked worktree, a
|
|
89
|
+
* submodule), a location variable, an include in the config, a format it
|
|
90
|
+
* cannot read, no repository at all.
|
|
91
|
+
*/
|
|
92
|
+
function repoFactsFromDisk(dir: string): RepoFacts | undefined {
|
|
93
|
+
if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
|
|
94
|
+
try {
|
|
95
|
+
const real = realpathSync(dir)
|
|
96
|
+
const dev = lstatSync(real).dev
|
|
97
|
+
for (let at = real; ;) {
|
|
98
|
+
const dotGit = path.join(at, '.git')
|
|
99
|
+
const st = lstatSync(dotGit, { throwIfNoEntry: false })
|
|
100
|
+
if (st !== undefined) {
|
|
101
|
+
if (!st.isDirectory() || !existsSync(path.join(dotGit, 'HEAD'))) return undefined
|
|
102
|
+
const objectFormat = configObjectFormat(readFileSync(path.join(dotGit, 'config'), 'utf8'))
|
|
103
|
+
if (objectFormat === undefined) return undefined
|
|
104
|
+
const below = path.relative(at, real).split(path.sep).join('/')
|
|
105
|
+
return {
|
|
106
|
+
prefix: below === '' ? '' : `${below}/`,
|
|
107
|
+
commonDir: path.relative(real, dotGit).split(path.sep).join('/'),
|
|
108
|
+
objectFormat,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const parent = path.dirname(at)
|
|
112
|
+
if (parent === at || lstatSync(parent).dev !== dev) return undefined
|
|
113
|
+
at = parent
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `[extensions] objectFormat` of a repository config, sha1 when unset; undefined when unsure. */
|
|
121
|
+
function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
|
|
122
|
+
let section = ''
|
|
123
|
+
let format: 'sha1' | 'sha256' = 'sha1'
|
|
124
|
+
for (const raw of config.split('\n')) {
|
|
125
|
+
const line = raw.trim()
|
|
126
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue
|
|
127
|
+
const head = /^\[\s*([A-Za-z0-9.-]+)/.exec(line)
|
|
128
|
+
if (head !== null) {
|
|
129
|
+
section = head[1]!.toLowerCase()
|
|
130
|
+
if (section === 'include' || section === 'includeif') return undefined
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (section !== 'extensions') continue
|
|
134
|
+
const kv = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line)
|
|
135
|
+
if (kv === null || kv[1]!.toLowerCase() !== 'objectformat') continue
|
|
136
|
+
const value = kv[2]!.toLowerCase()
|
|
137
|
+
if (value === 'sha256' || value === 'sha1') format = value
|
|
138
|
+
else return undefined
|
|
139
|
+
}
|
|
140
|
+
return format
|
|
141
|
+
}
|
|
142
|
+
|
|
69
143
|
export class GitFilesCache extends Map<string, readonly string[]> {
|
|
70
144
|
private changed = new Map<string, string[]>()
|
|
71
145
|
/**
|
package/src/cache/inputs.ts
CHANGED
|
@@ -1179,7 +1179,18 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1179
1179
|
// claims as an input. See the refusal below for why this is not simply
|
|
1180
1180
|
// honoured instead.
|
|
1181
1181
|
const unmatchedLiterals = new Set(plan.literals)
|
|
1182
|
-
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
1182
|
+
// First pass: glob-filter to candidate absolute paths (no I/O). Git
|
|
1183
|
+
// prints normalized relative paths, so under an absolute, normalized
|
|
1184
|
+
// project dir a join is a concatenation: `path.resolve` per file was
|
|
1185
|
+
// ~27 ms of a 900-task, 12,000-file warm run (I-30).
|
|
1186
|
+
const base =
|
|
1187
|
+
path.sep === '/' &&
|
|
1188
|
+
path.isAbsolute(args.projectDir) &&
|
|
1189
|
+
path.normalize(args.projectDir) === args.projectDir
|
|
1190
|
+
? args.projectDir.endsWith('/')
|
|
1191
|
+
? args.projectDir
|
|
1192
|
+
: `${args.projectDir}/`
|
|
1193
|
+
: undefined
|
|
1183
1194
|
const candidates: string[] = []
|
|
1184
1195
|
const verdicts = plan.verdicts
|
|
1185
1196
|
for (const rel of gitFiles) {
|
|
@@ -1193,7 +1204,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1193
1204
|
verdicts.set(rel, input)
|
|
1194
1205
|
}
|
|
1195
1206
|
if (!input || nested(rel)) continue
|
|
1196
|
-
candidates.push(path.resolve(args.projectDir, rel))
|
|
1207
|
+
candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
|
|
1197
1208
|
}
|
|
1198
1209
|
if (unmatchedLiterals.size > 0) {
|
|
1199
1210
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
@@ -1206,7 +1217,15 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1206
1217
|
// `git ls-files -s` can surface staged entries whose working-tree
|
|
1207
1218
|
// file is gone; the hasher would otherwise throw ENOENT.
|
|
1208
1219
|
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
1209
|
-
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
|
|
1220
|
+
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
|
|
1221
|
+
// Git's slice comes sorted and a common prefix keeps it so: one pass
|
|
1222
|
+
// proves it instead of a sort per task.
|
|
1223
|
+
for (let i = 1; i < resolved.length; i++) {
|
|
1224
|
+
if (resolved[i - 1]! > resolved[i]!) {
|
|
1225
|
+
resolved.sort()
|
|
1226
|
+
break
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1210
1229
|
// Stored only on the way out: a declaration whose literal named an
|
|
1211
1230
|
// invisible file threw above, and every task sharing it must throw too.
|
|
1212
1231
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
-
import { existsSync } from 'node:fs'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { constants as osConstants } from 'node:os'
|
|
9
9
|
import {
|
|
10
10
|
shellArgv,
|
|
@@ -769,15 +769,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
769
769
|
* `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
|
|
770
770
|
* 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
|
|
771
771
|
* Read after the child exits so it covers the whole task's span (the mark
|
|
772
|
-
* is monotonic). Linux reads
|
|
773
|
-
*
|
|
772
|
+
* is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
|
|
773
|
+
* bound in hand.
|
|
774
774
|
*/
|
|
775
775
|
export function ownRssHighWater(): number {
|
|
776
|
-
// getrusage's own peak
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
|
|
776
|
+
// `VmHWM`, not getrusage's own peak: that one also holds the peak of the
|
|
777
|
+
// image this process exec'd from, which under vfork is the PARENT's
|
|
778
|
+
// memory — vx spawned from a 300 MB test runner (or node) read a floor
|
|
779
|
+
// of 300 MB, and a task holding 150 MB reported no peak (I-40's
|
|
780
|
+
// regression, tests/own-rss-high-water.test.ts).
|
|
781
|
+
if (process.platform === 'linux') {
|
|
782
|
+
try {
|
|
783
|
+
const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
784
|
+
if (m !== null) return Number(m[1]) * 1024
|
|
785
|
+
} catch {
|
|
786
|
+
// /proc unreadable: fall through to the current RSS.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
781
789
|
return process.memoryUsage.rss()
|
|
782
790
|
}
|
|
783
791
|
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
existsSync,
|
|
34
34
|
mkdirSync,
|
|
35
35
|
openSync,
|
|
36
|
+
readFileSync,
|
|
36
37
|
readlinkSync,
|
|
37
38
|
realpathSync,
|
|
38
39
|
rmSync,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
executablePath,
|
|
61
62
|
grantPrefix,
|
|
62
63
|
isTmpdirRefusal,
|
|
64
|
+
procfsIsOwn,
|
|
63
65
|
TMPDIR_HINT,
|
|
64
66
|
UserError,
|
|
65
67
|
xxh3hex,
|
|
@@ -492,6 +494,22 @@ function bundledJavaAgent(): { javaAgentJarPath?: string } {
|
|
|
492
494
|
/** Whether this run's SRT scans at depth 1 and vx supplies the task-scoped denies (B-40). */
|
|
493
495
|
let scopedDenyScan = false
|
|
494
496
|
|
|
497
|
+
/**
|
|
498
|
+
* SRT's own deny scan, when `wrapSandboxedCommand` walks each task's write
|
|
499
|
+
* grants (B-40): none. SRT spawns its ripgrep on every wrap, and at depth
|
|
500
|
+
* 1 the scan finds only the root's entries, which SRT keeps only inside a
|
|
501
|
+
* write grant, where the scoped walk already reaches (the parity rows in
|
|
502
|
+
* `sandbox-deny-scan.unsafe.test.ts`). A no-op in rg's place ends the
|
|
503
|
+
* spawn's 3.8 ms at 1.0; SRT has no way to skip it.
|
|
504
|
+
*/
|
|
505
|
+
function scopedScanConfig(): { mandatoryDenySearchDepth: number; ripgrep?: { command: string } } {
|
|
506
|
+
try {
|
|
507
|
+
return { mandatoryDenySearchDepth: 1, ripgrep: { command: executablePath('true') } }
|
|
508
|
+
} catch {
|
|
509
|
+
return { mandatoryDenySearchDepth: 1 }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
495
513
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
496
514
|
if (process.platform !== 'linux') return {}
|
|
497
515
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -577,9 +595,7 @@ export async function initSandbox(opts?: {
|
|
|
577
595
|
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
578
596
|
ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
|
|
579
597
|
...linuxToolPaths(),
|
|
580
|
-
|
|
581
|
-
// walks each task's write grants for the rest (B-40).
|
|
582
|
-
...(scopedDenyScan ? { mandatoryDenySearchDepth: 1 } : {}),
|
|
598
|
+
...(scopedDenyScan ? scopedScanConfig() : {}),
|
|
583
599
|
...bundledJavaAgent(),
|
|
584
600
|
}
|
|
585
601
|
if (!srtUp) await unlinkStaleMuxSockets()
|
|
@@ -1053,7 +1069,10 @@ export async function wrapSandboxedCommand(
|
|
|
1053
1069
|
if (process.platform === 'linux' && !hostHasIpv6())
|
|
1054
1070
|
wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
|
|
1055
1071
|
if (args.server === true) liveServers.add(tag)
|
|
1056
|
-
if (ports.length > 0)
|
|
1072
|
+
if (ports.length > 0) {
|
|
1073
|
+
spawnHostBridges(ports, tag)
|
|
1074
|
+
await hostBridgesListen(ports, tag)
|
|
1075
|
+
}
|
|
1057
1076
|
return {
|
|
1058
1077
|
wrapped,
|
|
1059
1078
|
tag,
|
|
@@ -1306,12 +1325,47 @@ const hostBridges = new Map<
|
|
|
1306
1325
|
{ ports: readonly number[]; procs: Array<ReturnType<typeof Bun.spawn>> }
|
|
1307
1326
|
>()
|
|
1308
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* Wait until each host-side bridge listens on its port, so a server that
|
|
1330
|
+
* says it is ready inside the sandbox is reachable on the host: the socat
|
|
1331
|
+
* starts asynchronously, and a held server's port refused a connection
|
|
1332
|
+
* right after its ready line under I/O load (M-22). Read off
|
|
1333
|
+
* /proc/net/tcp (127.0.0.1, state 0A); skipped where /proc is not this
|
|
1334
|
+
* process's (its net table could be another namespace's), ended early by
|
|
1335
|
+
* a bridge that exited (a port already taken), and bounded at 5 s.
|
|
1336
|
+
*/
|
|
1337
|
+
async function hostBridgesListen(ports: readonly number[], tag: string): Promise<void> {
|
|
1338
|
+
if (!procfsIsOwn()) return
|
|
1339
|
+
const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
|
|
1340
|
+
const procs = hostBridges.get(tag)?.procs ?? []
|
|
1341
|
+
const until = Date.now() + 5_000
|
|
1342
|
+
while (Date.now() < until && procs.every((p) => p.exitCode === null)) {
|
|
1343
|
+
let table: string
|
|
1344
|
+
try {
|
|
1345
|
+
table = readFileSync('/proc/net/tcp', 'utf8')
|
|
1346
|
+
} catch {
|
|
1347
|
+
return
|
|
1348
|
+
}
|
|
1349
|
+
const listening = new Set<string>()
|
|
1350
|
+
for (const line of table.split('\n')) {
|
|
1351
|
+
const f = line.trim().split(/\s+/)
|
|
1352
|
+
if (f[3] === '0A' && f[1] !== undefined) listening.add(f[1])
|
|
1353
|
+
}
|
|
1354
|
+
if (want.every((w) => listening.has(w))) return
|
|
1355
|
+
await Bun.sleep(5)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1309
1359
|
function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
1310
1360
|
const procs: Array<ReturnType<typeof Bun.spawn>> = []
|
|
1311
1361
|
for (const p of ports) {
|
|
1312
1362
|
// A spawn failure (no socat on the host) is the task's to report:
|
|
1313
1363
|
// its own side dies the same way, in its frame.
|
|
1314
1364
|
try {
|
|
1365
|
+
// socat resolved on vx's PATH, as every tool vx spawns: by bare name
|
|
1366
|
+
// Bun.spawn walked the startup PATH (M-22).
|
|
1367
|
+
const [tool, ...rest] = portBridgeHostArgv(tag, p)
|
|
1368
|
+
const argv = [executablePath(tool!), ...rest]
|
|
1315
1369
|
// Guarded, in a group of its own (kill-tree.ts): a plain child of vx
|
|
1316
1370
|
// was in no group the guard lists, and a `kill -9` of vx left it
|
|
1317
1371
|
// listening on the port under init, where the next run's bridge
|
|
@@ -1319,7 +1373,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1319
1373
|
procs.push(
|
|
1320
1374
|
spawnGuarded((guard) =>
|
|
1321
1375
|
guard === undefined
|
|
1322
|
-
? Bun.spawn(
|
|
1376
|
+
? Bun.spawn(argv, {
|
|
1323
1377
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1324
1378
|
detached: true,
|
|
1325
1379
|
})
|
|
@@ -1327,7 +1381,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
|
|
|
1327
1381
|
[
|
|
1328
1382
|
executablePath('sh'),
|
|
1329
1383
|
'-c',
|
|
1330
|
-
`${guardLine(3)}exec ${
|
|
1384
|
+
`${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`,
|
|
1331
1385
|
],
|
|
1332
1386
|
{ stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
|
|
1333
1387
|
),
|
|
@@ -41,10 +41,16 @@ import type {
|
|
|
41
41
|
*/
|
|
42
42
|
const SYSCALLS = 'openat|access|statx|newfstatat'
|
|
43
43
|
const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
|
|
44
|
+
// The directory descriptor a call names, `-y`'s path included: taken up to
|
|
45
|
+
// the `, "` that opens the path argument, since a directory's name may hold
|
|
46
|
+
// a quote (`4</ws/q"d>`).
|
|
47
|
+
const DIRFD = '(?:AT_FDCWD|\\d+)(?:<.*?>)?, '
|
|
44
48
|
const STRACE_DONE_RE = new RegExp(
|
|
45
|
-
`^(\\d+)\\s+(${SYSCALLS})\\(
|
|
49
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
50
|
+
)
|
|
51
|
+
const STRACE_UNFINISHED_RE = new RegExp(
|
|
52
|
+
`^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*<unfinished`,
|
|
46
53
|
)
|
|
47
|
-
const STRACE_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+(${SYSCALLS})\\([^"]*${QUOTED}[^)]*<unfinished`)
|
|
48
54
|
const STRACE_RESUMED_RE = new RegExp(
|
|
49
55
|
`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
50
56
|
)
|
|
@@ -57,10 +63,10 @@ const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) res
|
|
|
57
63
|
*/
|
|
58
64
|
const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
|
|
59
65
|
const OPEN_READ_RE = new RegExp(
|
|
60
|
-
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(
|
|
66
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
|
|
61
67
|
)
|
|
62
68
|
const OPEN_READ_UNFINISHED_RE = new RegExp(
|
|
63
|
-
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(
|
|
69
|
+
`^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
|
|
64
70
|
)
|
|
65
71
|
const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
|
|
66
72
|
|
|
@@ -517,8 +517,9 @@ export function defaultLogger(
|
|
|
517
517
|
// output / only failed tasks print), so they stay silent — the tail
|
|
518
518
|
// is still captured and still bounded, just never printed. Guarded
|
|
519
519
|
// like the failures below: run() calls runEnd twice on the success
|
|
520
|
-
// path (once before the summary, once in its finally), and
|
|
521
|
-
//
|
|
520
|
+
// path (once before the summary, once in its finally), and though the
|
|
521
|
+
// bus delivers `run:end` once (`busLogger`), a renderer an embedder
|
|
522
|
+
// drives directly may hear both.
|
|
522
523
|
if (flushedPersistent) flushKept()
|
|
523
524
|
if (!flushedPersistent) {
|
|
524
525
|
flushedPersistent = true
|
|
@@ -211,9 +211,9 @@ export interface RunOptions {
|
|
|
211
211
|
*/
|
|
212
212
|
inflight?: Map<string, Promise<void>>
|
|
213
213
|
/**
|
|
214
|
-
* `--tag k=v` pairs (Tier-3). Persisted
|
|
215
|
-
*
|
|
216
|
-
*
|
|
214
|
+
* `--tag k=v` pairs (Tier-3). Persisted on the run's `invocations`
|
|
215
|
+
* row (JSON object) so dashboards can filter runs by label, a secret
|
|
216
|
+
* value masked (L-38). CLI parsing is the CLI's job.
|
|
217
217
|
*/
|
|
218
218
|
tags?: Record<string, string>
|
|
219
219
|
/**
|
|
@@ -443,22 +443,20 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
|
|
|
443
443
|
/**
|
|
444
444
|
* A plugin executor's throw from `execute`, named as every other hook's
|
|
445
445
|
* is: `plugin 'p' (executor 'e') failed in execute: …`. It read `internal
|
|
446
|
-
* error in <task>: pool down`, naming neither (C-63).
|
|
447
|
-
*
|
|
448
|
-
*
|
|
446
|
+
* error in <task>: pool down`, naming neither (C-63). A refusal, as
|
|
447
|
+
* `safe()` makes every stage's throw: the plugin is named, so it is not
|
|
448
|
+
* vx's bug, and an `internal error` line said it was (C-85). A fresh error
|
|
449
|
+
* with the plugin's as its cause, never the plugin's own renamed: one
|
|
450
|
+
* error an executor rejects every task with was named once per task
|
|
451
|
+
* (C-74). The floor's own throw is vx's and stays as it is.
|
|
449
452
|
*/
|
|
450
453
|
export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
|
|
451
|
-
if (!
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
err.message = `${prefix}${err.message}`
|
|
458
|
-
} catch {
|
|
459
|
-
// A frozen error keeps its own words.
|
|
460
|
-
}
|
|
461
|
-
return err
|
|
454
|
+
if (!executorPlugin.has(executor)) return err
|
|
455
|
+
const named = new UserError(
|
|
456
|
+
`${executorLabel(executor)} failed in execute: ${err instanceof Error ? err.message : String(err)}`,
|
|
457
|
+
)
|
|
458
|
+
named.cause = err
|
|
459
|
+
return named
|
|
462
460
|
}
|
|
463
461
|
|
|
464
462
|
/** How a message names `executor`: its plugin and its own name, or its name alone (the floor). */
|
|
@@ -539,7 +537,7 @@ export async function teardownPlugins(
|
|
|
539
537
|
}
|
|
540
538
|
} catch (err) {
|
|
541
539
|
warn(
|
|
542
|
-
`[vx] plugin '${plugin.name}' teardown
|
|
540
|
+
`[vx] plugin '${plugin.name}' failed in teardown: ${err instanceof Error ? err.message : String(err)}`,
|
|
543
541
|
)
|
|
544
542
|
}
|
|
545
543
|
}
|
|
@@ -109,7 +109,7 @@ export function captureGitContext(
|
|
|
109
109
|
* about the layout is unfamiliar (the caller then spawns git). Handles a
|
|
110
110
|
* `.git` directory, a `.git` FILE (`gitdir: …` — a linked worktree, whose
|
|
111
111
|
* refs live under `commondir`), a symbolic HEAD, a detached HEAD, loose refs
|
|
112
|
-
* and `packed-refs`.
|
|
112
|
+
* and `packed-refs`. A reftable repository is always null.
|
|
113
113
|
*/
|
|
114
114
|
function readHeadDirect(
|
|
115
115
|
workspaceRoot: string,
|
|
@@ -127,6 +127,16 @@ function readHeadDirect(
|
|
|
127
127
|
if (fs.existsSync(commonFile)) {
|
|
128
128
|
commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
|
|
129
129
|
}
|
|
130
|
+
// Reftable storage (`git init --ref-format=reftable`, git 2.45): HEAD
|
|
131
|
+
// names `refs/heads/.invalid` and the refs live in `reftable/`, so the
|
|
132
|
+
// files say nothing true about HEAD. A leftover `packed-refs` there was
|
|
133
|
+
// read as the answer; git is asked instead.
|
|
134
|
+
if (
|
|
135
|
+
fs.existsSync(path.join(gitDir, 'reftable')) ||
|
|
136
|
+
fs.existsSync(path.join(commonDir, 'reftable'))
|
|
137
|
+
) {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
130
140
|
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
|
131
141
|
const SHA = /^[0-9a-f]{40,64}$/
|
|
132
142
|
if (SHA.test(head)) return { commitSha: head, branch: null }
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -185,6 +185,21 @@ export function shouldShortCircuit(
|
|
|
185
185
|
return nodes.size > 0
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
function maskInvocation(command: string): string {
|
|
189
|
+
return secretMask([process.env])?.mask(command) ?? command
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A run's `--tag`s, stored on its history row and handed to telemetry
|
|
194
|
+
* (`vx.tag.<key>`): a tag that carries a secret is masked as the
|
|
195
|
+
* invocation line that spells it is (L-38).
|
|
196
|
+
*/
|
|
197
|
+
function maskTags(tags: Readonly<Record<string, string>>): Record<string, string> {
|
|
198
|
+
const mask = secretMask([process.env])
|
|
199
|
+
if (mask === null) return { ...tags }
|
|
200
|
+
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [mask.mask(k), mask.mask(v)]))
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
/**
|
|
189
204
|
* The command line a run hands to its telemetry sinks, with what follows
|
|
190
205
|
* `--` counted, not quoted. Those are the task's arguments,
|
|
@@ -192,10 +207,6 @@ export function shouldShortCircuit(
|
|
|
192
207
|
* GitHub job summary and a check-run posted over the API verbatim (item
|
|
193
208
|
* 1057). Local history (`vx last`) keeps the whole line, on this machine.
|
|
194
209
|
*/
|
|
195
|
-
function maskInvocation(command: string): string {
|
|
196
|
-
return secretMask([process.env])?.mask(command) ?? command
|
|
197
|
-
}
|
|
198
|
-
|
|
199
210
|
export function invocationCommand(argv: readonly string[]): string {
|
|
200
211
|
const sep = argv.indexOf('--')
|
|
201
212
|
if (sep === -1) return argv.join(' ')
|
|
@@ -208,15 +219,50 @@ export function invocationCommand(argv: readonly string[]): string {
|
|
|
208
219
|
* (C-61): a `concurrency` of 0, a negative or NaN left no worker slot and
|
|
209
220
|
* the run waited for good; a `retries` of NaN retried a failing task
|
|
210
221
|
* without end; a `timeout` of 0, a negative, NaN or past the timer's range
|
|
211
|
-
* killed every task at once.
|
|
222
|
+
* killed every task at once. And the words and shapes the CLI parses
|
|
223
|
+
* (C-86): a `continueMode` of `'sometimes'` ran as `deps-ok`, so a typo
|
|
224
|
+
* lost fail-fast without a word; a string `excludeDependencies` dropped
|
|
225
|
+
* nothing; a `projects` string or a `signal` that is no AbortSignal died
|
|
226
|
+
* a TypeError inside the run.
|
|
212
227
|
*/
|
|
213
|
-
function
|
|
214
|
-
const refuse = (name: string, value:
|
|
215
|
-
|
|
228
|
+
function refuseRunOptions(options: RunOptions): void {
|
|
229
|
+
const refuse = (name: string, value: unknown, rule: string): never => {
|
|
230
|
+
const shown =
|
|
231
|
+
typeof value === 'string'
|
|
232
|
+
? JSON.stringify(value)
|
|
233
|
+
: typeof value === 'number'
|
|
234
|
+
? String(value)
|
|
235
|
+
: value === null
|
|
236
|
+
? 'null'
|
|
237
|
+
: Array.isArray(value)
|
|
238
|
+
? 'an array'
|
|
239
|
+
: typeof value === 'object'
|
|
240
|
+
? 'an object'
|
|
241
|
+
: `a ${typeof value}`
|
|
242
|
+
throw new UserError(`RunOptions.${name} is ${shown}: it must be ${rule}`)
|
|
216
243
|
}
|
|
244
|
+
const names = (v: unknown): boolean => Array.isArray(v) && v.every((s) => typeof s === 'string')
|
|
245
|
+
if (!names(options.tasks)) refuse('tasks', options.tasks, 'an array of task names')
|
|
217
246
|
// No task named read "No projects declare task(s): ." (C-61).
|
|
218
247
|
if (options.tasks.length === 0 || options.tasks.includes(''))
|
|
219
248
|
throw new UserError(`RunOptions.tasks names no task: give at least one task name`)
|
|
249
|
+
if (options.projects !== undefined && !names(options.projects))
|
|
250
|
+
refuse('projects', options.projects, 'an array of project names')
|
|
251
|
+
if (options.forwardArgs !== undefined && !names(options.forwardArgs))
|
|
252
|
+
refuse('forwardArgs', options.forwardArgs, 'an array of strings')
|
|
253
|
+
const oneOf = (name: string, value: unknown, words: readonly string[]): void => {
|
|
254
|
+
if (value !== undefined && !words.includes(value as string))
|
|
255
|
+
refuse(name, value, `one of ${words.map((w) => `'${w}'`).join(', ')}`)
|
|
256
|
+
}
|
|
257
|
+
oneOf('continueMode', options.continueMode, ['never', 'deps-ok', 'always'])
|
|
258
|
+
oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
|
|
259
|
+
oneOf('download', options.download, ['all', 'toplevel', 'none'])
|
|
260
|
+
oneOf('flow', options.flow, ['focused', 'broad'])
|
|
261
|
+
const exclude = options.excludeDependencies
|
|
262
|
+
if (exclude !== undefined && exclude !== 'all' && !names(exclude))
|
|
263
|
+
refuse('excludeDependencies', exclude, "'all' or an array of task names")
|
|
264
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal))
|
|
265
|
+
refuse('signal', options.signal, 'an AbortSignal')
|
|
220
266
|
const { concurrency, retries, timeout } = options
|
|
221
267
|
if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency > 0))
|
|
222
268
|
refuse('concurrency', concurrency, 'a positive integer')
|
|
@@ -230,7 +276,7 @@ function refuseRunNumbers(options: RunOptions): void {
|
|
|
230
276
|
}
|
|
231
277
|
|
|
232
278
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
233
|
-
|
|
279
|
+
refuseRunOptions(options)
|
|
234
280
|
// Color decision: a custom logger (tests, embedders) handles its
|
|
235
281
|
// own formatting and asserts on plain strings, so we suppress
|
|
236
282
|
// ANSI escapes for them. Only the defaultLogger (real terminal
|
|
@@ -663,7 +709,7 @@ async function runOnBus(
|
|
|
663
709
|
arch: hostContext.arch,
|
|
664
710
|
workspaceId: wsIdentity.id,
|
|
665
711
|
workspaceName: wsIdentity.name,
|
|
666
|
-
tags: options.tags ?? {},
|
|
712
|
+
tags: maskTags(options.tags ?? {}),
|
|
667
713
|
}
|
|
668
714
|
telemetry = await subscribeTelemetry(
|
|
669
715
|
prepared.plugins,
|
|
@@ -1060,7 +1106,7 @@ async function runOnBus(
|
|
|
1060
1106
|
concurrency,
|
|
1061
1107
|
flow: options.flow ?? null,
|
|
1062
1108
|
forwardArgs: options.forwardArgs,
|
|
1063
|
-
tags: options.tags ?? {},
|
|
1109
|
+
tags: maskTags(options.tags ?? {}),
|
|
1064
1110
|
git: gitContext,
|
|
1065
1111
|
ci: ciContext,
|
|
1066
1112
|
host: hostContext,
|
|
@@ -1148,6 +1194,11 @@ async function runOnBus(
|
|
|
1148
1194
|
// Not on a stopped run: one stopped while it waited on another run's
|
|
1149
1195
|
// lock never held it, and its prune evicted under that run (item 858).
|
|
1150
1196
|
if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
|
|
1197
|
+
// A plugin hears the run until its teardown and nothing after: released
|
|
1198
|
+
// only in the finally, its handlers heard a kept server through the
|
|
1199
|
+
// whole keep-alive wait below (C-66). Idempotent; the finally's stay.
|
|
1200
|
+
disposePlugins?.()
|
|
1201
|
+
telemetry?.dispose()
|
|
1151
1202
|
await teardown()
|
|
1152
1203
|
await closeCache()
|
|
1153
1204
|
mark('close')
|
|
@@ -1302,6 +1353,7 @@ async function applyCacheRetention(prepared: PreparedRun, log: Logger): Promise<
|
|
|
1302
1353
|
* existence check — no artifact download, no ingest, no accessed_at bump.
|
|
1303
1354
|
*/
|
|
1304
1355
|
export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
1356
|
+
refuseRunOptions(options)
|
|
1305
1357
|
// The plan is the product and goes to stdout (`--dry=json` is parsed):
|
|
1306
1358
|
// what a stage says on the way goes to stderr (C-6).
|
|
1307
1359
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
@@ -237,6 +237,89 @@ function isWatcher(name: string, command: string): boolean {
|
|
|
237
237
|
)
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/** A server run alone (`serve website`, `http-server`), or a tool's own server verb. */
|
|
241
|
+
const SERVERS = new Set(['serve', 'http-server', 'live-server', 'sirv', 'webpack-dev-server'])
|
|
242
|
+
const SERVER_TOOLS = new Set([
|
|
243
|
+
'vite',
|
|
244
|
+
'next',
|
|
245
|
+
'astro',
|
|
246
|
+
'nuxt',
|
|
247
|
+
'nuxi',
|
|
248
|
+
'remix',
|
|
249
|
+
'react-router',
|
|
250
|
+
'docusaurus',
|
|
251
|
+
'storybook',
|
|
252
|
+
'netlify',
|
|
253
|
+
'wrangler',
|
|
254
|
+
'webpack',
|
|
255
|
+
'expo',
|
|
256
|
+
'react-native',
|
|
257
|
+
])
|
|
258
|
+
const SERVER_VERBS = new Set(['dev', 'serve', 'start', 'preview'])
|
|
259
|
+
const LAUNCHERS = new Set(['cross-env', 'npx', 'bunx', 'exec', 'dlx'])
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A command that runs a server (D-91): docusaurus's `serve website` and
|
|
263
|
+
* `netlify dev` mapped as one-shot tasks, so a dependent waited forever and
|
|
264
|
+
* a server that exits on stdin EOF ended at once. Read per `&&` segment,
|
|
265
|
+
* past env assignments and launchers; a quoted string is another tool's
|
|
266
|
+
* argument (`start-server-and-test 'vite preview' …` exits).
|
|
267
|
+
*/
|
|
268
|
+
function servesCommand(command: string): boolean {
|
|
269
|
+
for (const segment of foreground(command.replace(/"[^"]*"|'[^']*'/g, ' '))) {
|
|
270
|
+
const words = segment
|
|
271
|
+
.trim()
|
|
272
|
+
.split(/\s+/)
|
|
273
|
+
.filter((w) => w !== '')
|
|
274
|
+
let i = 0
|
|
275
|
+
while (
|
|
276
|
+
i < words.length &&
|
|
277
|
+
(/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) ||
|
|
278
|
+
LAUNCHERS.has(words[i]!) ||
|
|
279
|
+
words[i]!.startsWith('-') ||
|
|
280
|
+
(/^(pnpm|npm|yarn)$/.test(words[i]!) && LAUNCHERS.has(words[i + 1] ?? '')))
|
|
281
|
+
) {
|
|
282
|
+
// A launcher's `--package <name>` / `-p <name>` names what to install,
|
|
283
|
+
// not the program: docusaurus's `pnpm dlx --package netlify-cli netlify
|
|
284
|
+
// dev` read `netlify-cli` as the program (D-112).
|
|
285
|
+
i += words[i] === '--package' || words[i] === '-p' ? 2 : 1
|
|
286
|
+
}
|
|
287
|
+
const [program, verb] = [words[i], words[i + 1]]
|
|
288
|
+
if (program === undefined) continue
|
|
289
|
+
if (SERVERS.has(program)) return true
|
|
290
|
+
if (SERVER_TOOLS.has(program) && verb !== undefined && SERVER_VERBS.has(verb)) return true
|
|
291
|
+
if (program === 'vite' && (verb === undefined || verb.startsWith('-'))) return true
|
|
292
|
+
}
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether script `name` never exits: by name, as a watcher, as a server, or
|
|
298
|
+
* running such a script of its own package by name (docusaurus's
|
|
299
|
+
* `start:baseUrl`: `cross-env BASE_URL=… pnpm start`).
|
|
300
|
+
*/
|
|
301
|
+
function isPersistent(
|
|
302
|
+
name: string,
|
|
303
|
+
scripts: Readonly<Record<string, unknown>>,
|
|
304
|
+
seen: Set<string> = new Set(),
|
|
305
|
+
): boolean {
|
|
306
|
+
const own = scripts[name]
|
|
307
|
+
if (typeof own !== 'string' || seen.has(name)) return false
|
|
308
|
+
seen.add(name)
|
|
309
|
+
if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
|
|
310
|
+
return foreground(own).some((segment) =>
|
|
311
|
+
[...segment.matchAll(RUNS_SCRIPT)].some((m) => isPersistent(m[1]!, scripts, seen)),
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The `&&` / `;` / `|` segments of a command, less those it backgrounds with a single `&`. */
|
|
316
|
+
function foreground(command: string): string[] {
|
|
317
|
+
return command
|
|
318
|
+
.split(/&&|\|\||[;|()]/)
|
|
319
|
+
.flatMap((part) => part.split('&').slice(-1))
|
|
320
|
+
.filter((segment) => segment.trim() !== '')
|
|
321
|
+
}
|
|
322
|
+
|
|
240
323
|
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
241
324
|
// package.json is a boundary: `scripts` is whatever the file holds. A
|
|
242
325
|
// string or an array would enumerate its indices as script names.
|
|
@@ -343,6 +426,38 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
343
426
|
return false
|
|
344
427
|
}
|
|
345
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The manager a root's files name, or undefined: `pnpm-workspace.yaml` is
|
|
431
|
+
* pnpm's whatever the lockfile, and the npm `ownerOf` falls back to at the
|
|
432
|
+
* file-system root with no lockfile anywhere is no claim.
|
|
433
|
+
*/
|
|
434
|
+
function rootManager(dir: string, memo: Map<string, Owner>): string | undefined {
|
|
435
|
+
if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return 'pnpm'
|
|
436
|
+
const owner = ownerOf(dir, memo)
|
|
437
|
+
const guessed =
|
|
438
|
+
owner.at === path.parse(owner.at).root && !existsSync(path.join(owner.at, 'package-lock.json'))
|
|
439
|
+
return guessed ? undefined : owner.manager
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* How the repo's own manager runs the members, for the root note: an npm
|
|
444
|
+
* repo read "(`pnpm -r`, `--filter`, a runner)" (insomnia).
|
|
445
|
+
*/
|
|
446
|
+
function membersExample(manager: string | undefined): string {
|
|
447
|
+
switch (manager) {
|
|
448
|
+
case 'npm':
|
|
449
|
+
return '`--workspaces`, `-w`'
|
|
450
|
+
case 'yarn':
|
|
451
|
+
return '`yarn workspaces run`, `yarn workspace`'
|
|
452
|
+
case 'berry':
|
|
453
|
+
return '`yarn workspaces foreach`, `yarn workspace`'
|
|
454
|
+
case 'bun':
|
|
455
|
+
return '`bun --filter`'
|
|
456
|
+
default:
|
|
457
|
+
return '`pnpm -r`, `--filter`'
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
346
461
|
/**
|
|
347
462
|
* The hooks a package's build hides in when it has no `build` script:
|
|
348
463
|
* react-navigation's twelve packages build in `prepack: bob build`, their
|
|
@@ -395,6 +510,48 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
|
|
|
395
510
|
return undefined
|
|
396
511
|
}
|
|
397
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The package globs a `lerna.json` beside a lone root lists (Lerna's
|
|
515
|
+
* default when it names none), or undefined: Lerna-classic repos list
|
|
516
|
+
* their packages there, not in `workspaces`, and vx saw the root alone
|
|
517
|
+
* (D-111).
|
|
518
|
+
*/
|
|
519
|
+
function lernaPackages(dir: string): string[] | undefined {
|
|
520
|
+
let json: unknown
|
|
521
|
+
try {
|
|
522
|
+
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
|
|
523
|
+
} catch {
|
|
524
|
+
return undefined
|
|
525
|
+
}
|
|
526
|
+
const listed = (json as { packages?: unknown } | null)?.packages
|
|
527
|
+
return Array.isArray(listed) && listed.every((g) => typeof g === 'string') && listed.length > 0
|
|
528
|
+
? listed
|
|
529
|
+
: ['packages/*']
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function lernaNote(globs: readonly string[]): string {
|
|
533
|
+
const list = globs.map((g) => JSON.stringify(g)).join(', ')
|
|
534
|
+
return `lerna.json lists the packages (${list}), but package.json declares no \`workspaces\`, so vx sees the root alone: add \`"workspaces": [${list}]\` to package.json and run \`vx init\` again`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Yarn 2+ installs with Plug'n'Play unless `.yarnrc.yml` names another
|
|
539
|
+
* linker: a package's bins live in `.pnp.cjs`, and a task's `json5` exited
|
|
540
|
+
* 127 under vx, which runs no `yarn` in front of a command (probed on Yarn
|
|
541
|
+
* 4.5).
|
|
542
|
+
*/
|
|
543
|
+
function usesPnp(dir: string): boolean {
|
|
544
|
+
let rc = ''
|
|
545
|
+
try {
|
|
546
|
+
rc = readFileSync(path.join(dir, '.yarnrc.yml'), 'utf8')
|
|
547
|
+
} catch {}
|
|
548
|
+
const linker = /^nodeLinker:\s*["']?([\w-]+)/m.exec(rc)?.[1]
|
|
549
|
+
return linker === undefined || linker === 'pnp'
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const PNP_NOTE =
|
|
553
|
+
"Yarn Plug'n'Play installs this repo: a package's bins live in `.pnp.cjs`, not `node_modules/.bin`, so a task's `tsc` is not found under vx — set `nodeLinker: node-modules` in `.yarnrc.yml` and run `yarn install`, or write each command as `yarn exec '<command>'`"
|
|
554
|
+
|
|
398
555
|
/** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
|
|
399
556
|
const RUNS_SCRIPT =
|
|
400
557
|
/(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
@@ -559,7 +716,7 @@ export function migrateScripts(
|
|
|
559
716
|
: undefined)
|
|
560
717
|
if (rootName !== undefined && rootMapped > 0) {
|
|
561
718
|
notes.push(
|
|
562
|
-
`${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (
|
|
719
|
+
`${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (${membersExample(rootMeta && rootManager(rootMeta.dir, hookMemo))}, a runner) or share a member's task name are left out`,
|
|
563
720
|
)
|
|
564
721
|
} else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
565
722
|
// react's nameless root: "its scripts run the workspace" was not why,
|
|
@@ -671,7 +828,7 @@ export function migrateScripts(
|
|
|
671
828
|
)
|
|
672
829
|
}
|
|
673
830
|
const task: Record<string, unknown> = { exec }
|
|
674
|
-
if (
|
|
831
|
+
if (isPersistent(name, scripts)) {
|
|
675
832
|
exec['persistent'] = {}
|
|
676
833
|
todos.push(PERSISTENT_TODO)
|
|
677
834
|
}
|
|
@@ -698,6 +855,9 @@ export function migrateScripts(
|
|
|
698
855
|
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
699
856
|
}
|
|
700
857
|
}
|
|
858
|
+
const owner = metas[0] === undefined ? undefined : ownerOf(metas[0].dir, hookMemo)
|
|
859
|
+
if (owner?.manager === 'berry' && usesPnp(owner.at)) notes.push(PNP_NOTE)
|
|
860
|
+
|
|
701
861
|
if (lifecycleBuilds.length > 0) {
|
|
702
862
|
const [name, hook, command] = lifecycleBuilds[0]!
|
|
703
863
|
const more = lifecycleBuilds.length - 1
|
|
@@ -705,6 +865,8 @@ export function migrateScripts(
|
|
|
705
865
|
`${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
866
|
)
|
|
707
867
|
}
|
|
868
|
+
const lerna = metas.length === 1 ? lernaPackages(metas[0]!.dir) : undefined
|
|
869
|
+
if (lerna !== undefined) notes.push(lernaNote(lerna))
|
|
708
870
|
breakBuildCycles(projects, metas)
|
|
709
871
|
pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
|
|
710
872
|
return {
|
|
@@ -134,6 +134,17 @@ export function buildPackageGraph(
|
|
|
134
134
|
// built on the first one, as the closures are.
|
|
135
135
|
const reachDeps = new Map<string, string[]>()
|
|
136
136
|
const directDependents = new Map<string, string[]>()
|
|
137
|
+
/** One project's REACH list, made on first ask (`buildReach` makes them all). */
|
|
138
|
+
const reachOf = (name: string): string[] => {
|
|
139
|
+
let reach = reachDeps.get(name)
|
|
140
|
+
if (reach === undefined) {
|
|
141
|
+
const own = order.get(name)
|
|
142
|
+
if (own === undefined) return []
|
|
143
|
+
reach = [...new Set([...own, ...peers.get(name)!])].sort()
|
|
144
|
+
reachDeps.set(name, reach)
|
|
145
|
+
}
|
|
146
|
+
return reach
|
|
147
|
+
}
|
|
137
148
|
let reachBuilt = false
|
|
138
149
|
const buildReach = (): void => {
|
|
139
150
|
if (reachBuilt) return
|
|
@@ -224,13 +235,40 @@ export function buildPackageGraph(
|
|
|
224
235
|
// does a `^task` walk (it reads `directDeps`). Building both closures
|
|
225
236
|
// eagerly was 12 ms of a 240 ms warm run at 1000 projects × 30 deps
|
|
226
237
|
// (profiled 2026-09-09) for answers nobody read.
|
|
227
|
-
|
|
238
|
+
// A scoped run asks one or two seeds' closures: a search from each over
|
|
239
|
+
// the REACH lists it visits answers them without every project's list
|
|
240
|
+
// and the whole graph's bitsets (8–13 ms at 1,000 projects for one
|
|
241
|
+
// `--filter`). The same set, in the same name order; past a few asks
|
|
242
|
+
// the bitsets are cheaper per question.
|
|
243
|
+
const EARLY_SEARCHES = 8
|
|
244
|
+
const searchDeps = (name: string): string[] => {
|
|
245
|
+
const seen = new Set<string>()
|
|
246
|
+
const stack = [...reachOf(name)]
|
|
247
|
+
while (stack.length > 0) {
|
|
248
|
+
const cur = stack.pop()!
|
|
249
|
+
if (seen.has(cur)) continue
|
|
250
|
+
seen.add(cur)
|
|
251
|
+
for (const d of reachOf(cur)) stack.push(d)
|
|
252
|
+
}
|
|
253
|
+
return [...seen].sort()
|
|
254
|
+
}
|
|
255
|
+
function makeAccessor(
|
|
256
|
+
edges: Map<string, string[]>,
|
|
257
|
+
search?: (name: string) => string[],
|
|
258
|
+
): (name: string) => string[] {
|
|
228
259
|
const memo = new Map<string, string[]>()
|
|
229
260
|
let closures: Uint32Array | null | undefined
|
|
230
261
|
const words = (names.length + 31) >>> 5
|
|
262
|
+
let searched = 0
|
|
231
263
|
return (name) => {
|
|
232
264
|
const cached = memo.get(name)
|
|
233
265
|
if (cached) return cached
|
|
266
|
+
if (search !== undefined && closures === undefined && searched < EARLY_SEARCHES) {
|
|
267
|
+
searched++
|
|
268
|
+
const result = search(name)
|
|
269
|
+
memo.set(name, result)
|
|
270
|
+
return result
|
|
271
|
+
}
|
|
234
272
|
buildReach()
|
|
235
273
|
if (closures === undefined) closures = bitsetClosures(edges)
|
|
236
274
|
if (closures === null) {
|
|
@@ -257,7 +295,7 @@ export function buildPackageGraph(
|
|
|
257
295
|
|
|
258
296
|
return {
|
|
259
297
|
directDeps: (name) => directDeps.get(name) ?? [],
|
|
260
|
-
transitiveDeps: makeAccessor(reachDeps),
|
|
298
|
+
transitiveDeps: makeAccessor(reachDeps, searchDeps),
|
|
261
299
|
transitiveDependents: makeAccessor(directDependents),
|
|
262
300
|
}
|
|
263
301
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { realpathSync, statSync } from 'node:fs'
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
4
|
import { UserError, xxh3hex } from '../util/index.js'
|
|
@@ -200,6 +200,14 @@ const configExit = ((code?: number | string | null) => {
|
|
|
200
200
|
throw new Error(`process.exit(${code ?? ''}) in a config: ${CONFIG_EXIT}`)
|
|
201
201
|
}) as typeof process.exit
|
|
202
202
|
|
|
203
|
+
/** A `.pnp.cjs` in `dir` or above: Yarn Plug'n'Play's install. */
|
|
204
|
+
function pnpAbove(dir: string): boolean {
|
|
205
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
206
|
+
if (existsSync(path.join(d, '.pnp.cjs'))) return true
|
|
207
|
+
if (path.dirname(d) === d) return false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
203
211
|
/**
|
|
204
212
|
* A bare import nothing above the config provides is refused BEFORE the
|
|
205
213
|
* evaluation: left to Bun, a workspace with no `node_modules` would have
|
|
@@ -215,6 +223,14 @@ function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: st
|
|
|
215
223
|
loader,
|
|
216
224
|
)
|
|
217
225
|
if (missing.length === 0) return
|
|
226
|
+
// Under Yarn Plug'n'Play the dependencies ARE installed, into a
|
|
227
|
+
// `.pnp.cjs` Bun does not read: "install them first" sent the user to a
|
|
228
|
+
// `yarn install` that changed nothing (D-109).
|
|
229
|
+
if (pnpAbove(path.dirname(configPath))) {
|
|
230
|
+
throw new UserError(
|
|
231
|
+
`${kind} config ${configPath}: cannot find '${missing[0]}' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set \`nodeLinker: node-modules\` in .yarnrc.yml and run \`yarn install\``,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
218
234
|
throw new UserError(
|
|
219
235
|
`${kind} config ${configPath}: cannot find '${missing[0]}' — no node_modules above the config provides it; install the workspace's dependencies first`,
|
|
220
236
|
)
|