@vzn/vx 0.0.590 → 0.0.594
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/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/cache/archive.ts +16 -5
- package/src/cache/cache.ts +41 -11
- package/src/cache/git-inputs.ts +14 -9
- package/src/cache/inputs.ts +47 -17
- package/src/cache/tar-stream.ts +3 -1
- package/src/cli/cache.ts +3 -5
- package/src/cli/foreign-flags.ts +33 -4
- package/src/cli/help.ts +22 -5
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +3 -3
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/run.ts +65 -37
- package/src/cli/select.ts +14 -2
- package/src/cli/show.ts +5 -6
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +7 -6
- package/src/cli/watch-fs.ts +17 -1
- package/src/cli/watch.ts +22 -4
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/exec/env.ts +3 -5
- package/src/exec/runner.ts +28 -7
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-runtime.ts +69 -19
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/scheduler.ts +13 -3
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +3 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +14 -3
- package/src/orchestrator/hit-restore.ts +3 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/lockfile-claim.ts +2 -3
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plugin-host.ts +16 -0
- package/src/orchestrator/plugin.ts +9 -13
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +42 -8
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/workspace/affected.ts +17 -11
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +38 -7
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +22 -5
- package/src/workspace/project-loader.ts +17 -2
- package/src/workspace/workspace.ts +40 -4
package/launcher.cjs
CHANGED
|
@@ -37,8 +37,15 @@ const SUPPORTED = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64']
|
|
|
37
37
|
const key = `${process.platform}-${process.arch}`
|
|
38
38
|
const args = process.argv.slice(2)
|
|
39
39
|
|
|
40
|
+
// The prebuilt Linux binaries link glibc. On musl (Alpine) the loader they
|
|
41
|
+
// name is absent, execve fails with ENOENT on a file that exists, and the
|
|
42
|
+
// user read "failed to launch (spawn … ENOENT)". Treated as no binary here,
|
|
43
|
+
// so Bun runs the source when present and the error names the cause.
|
|
44
|
+
const GLIBC_LOADER = { x64: '/lib64/ld-linux-x86-64.so.2', arm64: '/lib/ld-linux-aarch64.so.1' }
|
|
45
|
+
const noGlibc = process.platform === 'linux' && !existsSync(GLIBC_LOADER[process.arch] ?? '/')
|
|
46
|
+
|
|
40
47
|
function platformBinary() {
|
|
41
|
-
if (!SUPPORTED.includes(key)) return undefined
|
|
48
|
+
if (!SUPPORTED.includes(key) || noGlibc) return undefined
|
|
42
49
|
const platformPkg = `${name}-${key}`
|
|
43
50
|
try {
|
|
44
51
|
// No `exports` restriction on the platform packages, so package.json
|
|
@@ -119,7 +126,7 @@ if (bin !== undefined) {
|
|
|
119
126
|
} else {
|
|
120
127
|
const supported = SUPPORTED.join(', ')
|
|
121
128
|
process.stderr.write(
|
|
122
|
-
`${base}: no prebuilt binary for ${key}.\n` +
|
|
129
|
+
`${base}: no prebuilt binary for ${key}${noGlibc ? ' without glibc (musl)' : ''}.\n` +
|
|
123
130
|
` Supported platforms: ${supported}.\n` +
|
|
124
131
|
` If your platform should be supported, reinstall so npm fetches the\n` +
|
|
125
132
|
` matching ${name}-${key} optionalDependency, or install Bun (>=1.4) to\n` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.594",
|
|
4
4
|
"description": "The fastest 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.594",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.594",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.594",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.594"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -563,6 +563,8 @@ interface Staged {
|
|
|
563
563
|
target: string
|
|
564
564
|
/** The topmost directory `mkdir -p` created for it, pruned on abort. */
|
|
565
565
|
created: string | undefined
|
|
566
|
+
/** Written by Bun's file writer, which creates with 0664 where `writeFile` uses 0666 (before the umask). */
|
|
567
|
+
streamed: boolean
|
|
566
568
|
}
|
|
567
569
|
|
|
568
570
|
/**
|
|
@@ -761,7 +763,8 @@ class Extractor {
|
|
|
761
763
|
path.dirname(target),
|
|
762
764
|
`.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
|
|
763
765
|
)
|
|
764
|
-
|
|
766
|
+
const streamed = !(body instanceof Uint8Array)
|
|
767
|
+
this.staged.push({ name, tmp, target, created, streamed })
|
|
765
768
|
if (body instanceof Uint8Array) {
|
|
766
769
|
this.inflightBytes += body.byteLength
|
|
767
770
|
// `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
|
|
@@ -803,17 +806,23 @@ class Extractor {
|
|
|
803
806
|
* (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
|
|
804
807
|
* archive bench). A yield every `COMMIT_BATCH` files keeps the other
|
|
805
808
|
* restores' round trips flowing. The chmod is skipped when the mode is
|
|
806
|
-
* the one the temp file was created with — the common case.
|
|
809
|
+
* the one the temp file was created with — the common case. The two
|
|
810
|
+
* writers create with different modes (under umask 000: 0666 and 0664),
|
|
811
|
+
* so each is stat'ed once.
|
|
807
812
|
*/
|
|
808
813
|
async commit(
|
|
809
814
|
metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
|
|
810
815
|
): Promise<void> {
|
|
811
816
|
await this.drain()
|
|
812
|
-
const
|
|
813
|
-
const createdMode = first === undefined ? -1 : statSync(first.tmp).mode & 0o777
|
|
817
|
+
const createdModes = new Map<boolean, number>()
|
|
814
818
|
let n = 0
|
|
815
819
|
for (const s of this.staged) {
|
|
816
820
|
const [mode, mtimeMs] = metaFor(s.name)
|
|
821
|
+
let createdMode = createdModes.get(s.streamed)
|
|
822
|
+
if (createdMode === undefined) {
|
|
823
|
+
createdMode = statSync(s.tmp).mode & 0o777
|
|
824
|
+
createdModes.set(s.streamed, createdMode)
|
|
825
|
+
}
|
|
817
826
|
if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
|
|
818
827
|
if (mtimeMs !== undefined) {
|
|
819
828
|
// A Date, not seconds: Bun reads a negative number of seconds as
|
|
@@ -837,7 +846,9 @@ class Extractor {
|
|
|
837
846
|
await Promise.allSettled(this.inflight)
|
|
838
847
|
this.inflight = []
|
|
839
848
|
await Promise.all(this.staged.map((s) => unlink(s.tmp).catch(() => undefined)))
|
|
840
|
-
|
|
849
|
+
// Newest first: a later entry's chain can sit inside an earlier one's
|
|
850
|
+
// (`dist/` then `dist/sub/`), never the reverse.
|
|
851
|
+
for (const s of this.staged.toReversed()) {
|
|
841
852
|
if (s.created === undefined) continue
|
|
842
853
|
let dir = path.dirname(s.target)
|
|
843
854
|
const top = path.resolve(s.created)
|
package/src/cache/cache.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
lstatSync,
|
|
43
43
|
mkdirSync,
|
|
44
44
|
openSync,
|
|
45
|
+
readdirSync,
|
|
45
46
|
renameSync,
|
|
46
47
|
writeFileSync,
|
|
47
48
|
} from 'node:fs'
|
|
@@ -373,7 +374,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
373
374
|
* it there", and the ignore file is created exclusively instead of probed
|
|
374
375
|
* first. An existing cache costs three calls; it cost six.
|
|
375
376
|
*/
|
|
376
|
-
function openCacheDir(cacheDir: string): string | null {
|
|
377
|
+
function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
377
378
|
const ignore = path.join(cacheDir, '.gitignore')
|
|
378
379
|
try {
|
|
379
380
|
accessSync(cacheDir, constants.W_OK)
|
|
@@ -390,7 +391,7 @@ function openCacheDir(cacheDir: string): string | null {
|
|
|
390
391
|
// A FILE at `cacheDir` passed the `access` above; mkdir names it.
|
|
391
392
|
if (code === 'ENOTDIR') makeCacheDir(cacheDir)
|
|
392
393
|
if (code !== 'ENOENT') return errorText(err)
|
|
393
|
-
refuseManifestDir(cacheDir)
|
|
394
|
+
refuseManifestDir(cacheDir, workspaceRoot)
|
|
394
395
|
}
|
|
395
396
|
try {
|
|
396
397
|
writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
|
|
@@ -456,21 +457,50 @@ const IGNORE_ALL = '*\n'
|
|
|
456
457
|
* the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
|
|
457
458
|
* `*` ignore file above hid every file in it from git, so its inputs
|
|
458
459
|
* matched nothing and a changed source replayed the old output, and the
|
|
459
|
-
* artifacts landed among the sources (item 997).
|
|
460
|
-
*
|
|
460
|
+
* artifacts landed among the sources (item 997). A directory that holds
|
|
461
|
+
* projects (`'packages'`) or the workspace (`'..'`, `'/'`) does the same
|
|
462
|
+
* one level up. Asked only when there is no index yet, so an open cache
|
|
463
|
+
* pays nothing for it.
|
|
461
464
|
*/
|
|
462
|
-
function refuseManifestDir(cacheDir: string): void {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
+
function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined): void {
|
|
466
|
+
const manifestIn = (dir: string): string | undefined =>
|
|
467
|
+
MANIFESTS.find((m) => existsSync(path.join(dir, m)))
|
|
468
|
+
const own = manifestIn(cacheDir)
|
|
469
|
+
if (own !== undefined) {
|
|
465
470
|
throw new UserError(
|
|
466
|
-
`cache directory ${cacheDir} holds a ${
|
|
471
|
+
`cache directory ${cacheDir} holds a ${own}: it is the workspace's or a project's own ` +
|
|
467
472
|
`directory, and vx would keep its index there under a \`*\` .gitignore that hides every file ` +
|
|
468
|
-
`in it from git and from the cache keys.
|
|
469
|
-
|
|
473
|
+
`in it from git and from the cache keys. ${OWN_DIR_HINT}`,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
let held: string | undefined
|
|
477
|
+
if (workspaceRoot !== undefined) {
|
|
478
|
+
const rel = path.relative(cacheDir, workspaceRoot)
|
|
479
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) held = workspaceRoot
|
|
480
|
+
}
|
|
481
|
+
if (held === undefined) {
|
|
482
|
+
for (const entry of readdirSync(cacheDir, { withFileTypes: true })) {
|
|
483
|
+
if (!entry.isDirectory()) continue
|
|
484
|
+
const sub = path.join(cacheDir, entry.name)
|
|
485
|
+
if (manifestIn(sub) !== undefined) {
|
|
486
|
+
held = sub
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (held !== undefined) {
|
|
492
|
+
throw new UserError(
|
|
493
|
+
`cache directory ${cacheDir} holds ${held}, a project or workspace directory, and vx would keep ` +
|
|
494
|
+
`its index there under a \`*\` .gitignore that hides it from git and from the cache keys. ` +
|
|
495
|
+
OWN_DIR_HINT,
|
|
470
496
|
)
|
|
471
497
|
}
|
|
472
498
|
}
|
|
473
499
|
|
|
500
|
+
const MANIFESTS = ['package.json', 'pnpm-workspace.yaml']
|
|
501
|
+
const OWN_DIR_HINT =
|
|
502
|
+
'Point `cacheDir` in vx.workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
|
|
503
|
+
|
|
474
504
|
function makeCacheDir(cacheDir: string): void {
|
|
475
505
|
try {
|
|
476
506
|
mkdirSync(cacheDir, { recursive: true })
|
|
@@ -658,7 +688,7 @@ export class Cache implements CacheLayer {
|
|
|
658
688
|
const dbFile = path.join(cacheDir, 'cache.db')
|
|
659
689
|
this.dbFile = dbFile
|
|
660
690
|
const absent = mode === 'inspect' && !existsSync(dbFile)
|
|
661
|
-
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
|
|
691
|
+
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
|
|
662
692
|
this.write = localPolicy.write && this.writeBlocked === null
|
|
663
693
|
try {
|
|
664
694
|
this.db = new Database(absent ? ':memory:' : dbFile, { create: true })
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
10
10
|
import {
|
|
11
|
-
UserError,
|
|
12
11
|
executablePath,
|
|
13
12
|
gitSpawnRefusal,
|
|
14
13
|
isInstalledPath,
|
|
15
14
|
notAWorkTree,
|
|
15
|
+
relPosix,
|
|
16
|
+
UserError,
|
|
16
17
|
xxh3hex,
|
|
17
18
|
} from '../util/index.js'
|
|
18
19
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
@@ -70,7 +71,9 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
70
71
|
// One line per flag, in order. A git that does not know
|
|
71
72
|
// `--show-object-format` echoes it back, as it does any unknown flag,
|
|
72
73
|
// which reads as sha1 — what the flag's own spawn answered there too.
|
|
73
|
-
const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder(
|
|
74
|
+
const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder('utf-8', {
|
|
75
|
+
ignoreBOM: true,
|
|
76
|
+
})
|
|
74
77
|
.decode(proc.stdout)
|
|
75
78
|
.split('\n')
|
|
76
79
|
.map((l) => l.trim())
|
|
@@ -115,10 +118,10 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
|
|
|
115
118
|
if (!st.isDirectory() || !existsSync(path.join(dotGit, 'HEAD'))) return undefined
|
|
116
119
|
const objectFormat = configObjectFormat(readFileSync(path.join(dotGit, 'config'), 'utf8'))
|
|
117
120
|
if (objectFormat === undefined) return undefined
|
|
118
|
-
const below =
|
|
121
|
+
const below = relPosix(at, real)
|
|
119
122
|
return {
|
|
120
123
|
prefix: below === '' ? '' : `${below}/`,
|
|
121
|
-
commonDir:
|
|
124
|
+
commonDir: relPosix(real, dotGit),
|
|
122
125
|
objectFormat,
|
|
123
126
|
indexFile: path.relative(real, path.join(dotGit, 'index')).split(path.sep).join('/'),
|
|
124
127
|
}
|
|
@@ -270,7 +273,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
270
273
|
for (const rel of relPaths) {
|
|
271
274
|
const abs = path.resolve(workspaceRoot, rel)
|
|
272
275
|
if (abs.startsWith(key + path.sep)) {
|
|
273
|
-
under.push(
|
|
276
|
+
under.push(relPosix(key, abs))
|
|
274
277
|
}
|
|
275
278
|
}
|
|
276
279
|
if (under.length > 0) this.recordChanged(key, under)
|
|
@@ -427,8 +430,10 @@ function decodeGitZ(bytes: Uint8Array): { text: string; undecodable: Set<string>
|
|
|
427
430
|
return { text, undecodable }
|
|
428
431
|
}
|
|
429
432
|
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
// A default decoder strips a leading U+FEFF: the first path git lists
|
|
434
|
+
// would lose it and name no file.
|
|
435
|
+
const LOSSY_UTF8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
436
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
432
437
|
|
|
433
438
|
const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
|
|
434
439
|
|
|
@@ -1193,7 +1198,7 @@ export function gitPathspecs(
|
|
|
1193
1198
|
projectDirs: readonly string[],
|
|
1194
1199
|
workspaceWide: boolean,
|
|
1195
1200
|
): string[] {
|
|
1196
|
-
const rels = projectDirs.map((d) =>
|
|
1201
|
+
const rels = projectDirs.map((d) => relPosix(workspaceRoot, d))
|
|
1197
1202
|
const scoped =
|
|
1198
1203
|
!workspaceWide &&
|
|
1199
1204
|
rels.length > 0 &&
|
|
@@ -1555,7 +1560,7 @@ export async function applyGitEnumeration(
|
|
|
1555
1560
|
const relPrefix =
|
|
1556
1561
|
base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
|
|
1557
1562
|
? projectDir.slice(base.length).replace(/\/$/, '')
|
|
1558
|
-
:
|
|
1563
|
+
: relPosix(workspaceRoot, projectDir)
|
|
1559
1564
|
if (relPrefix === '' || relPrefix === '.') {
|
|
1560
1565
|
cache.set(projectDir, all)
|
|
1561
1566
|
const rootOids = new Map<string, string>()
|
package/src/cache/inputs.ts
CHANGED
|
@@ -28,9 +28,10 @@ import {
|
|
|
28
28
|
isLiteralPattern,
|
|
29
29
|
normalizeGlob,
|
|
30
30
|
outputMatcher,
|
|
31
|
+
relPosix,
|
|
31
32
|
shellArgv,
|
|
32
|
-
splitNegations,
|
|
33
33
|
slashBraceExpansions,
|
|
34
|
+
splitNegations,
|
|
34
35
|
staticPrefix,
|
|
35
36
|
taskGlob,
|
|
36
37
|
UserError,
|
|
@@ -417,7 +418,11 @@ async function runRuntimeCommand(
|
|
|
417
418
|
owner: RuntimeMemo | undefined,
|
|
418
419
|
): Promise<string> {
|
|
419
420
|
const ambient = process.env['PATH']
|
|
420
|
-
|
|
421
|
+
// As the task's PATH does (exec/env.ts): a dir holding the delimiter
|
|
422
|
+
// splits into an entry relative to the probe's cwd.
|
|
423
|
+
const PATH = [...binDirs.filter((dir) => !dir.includes(path.delimiter)), ambient]
|
|
424
|
+
.filter((entry) => entry)
|
|
425
|
+
.join(path.delimiter)
|
|
421
426
|
let proc
|
|
422
427
|
try {
|
|
423
428
|
// vx's own `sh`, resolved on its PATH before the probe's: Bun.spawn looks
|
|
@@ -425,7 +430,7 @@ async function runRuntimeCommand(
|
|
|
425
430
|
// `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
|
|
426
431
|
proc = Bun.spawn(shellArgv(command), {
|
|
427
432
|
cwd,
|
|
428
|
-
env: { ...process.env, PATH
|
|
433
|
+
env: { ...process.env, PATH },
|
|
429
434
|
stdin: 'ignore',
|
|
430
435
|
stdout: 'pipe',
|
|
431
436
|
stderr: 'pipe',
|
|
@@ -454,22 +459,49 @@ async function runRuntimeCommand(
|
|
|
454
459
|
let stdout, stderr, exitCode
|
|
455
460
|
try {
|
|
456
461
|
;[stdout, stderr, exitCode] = await Promise.all([
|
|
457
|
-
new Response(proc.stdout).
|
|
458
|
-
new Response(proc.stderr).
|
|
462
|
+
new Response(proc.stdout).bytes(),
|
|
463
|
+
new Response(proc.stderr).bytes(),
|
|
459
464
|
proc.exited,
|
|
460
465
|
])
|
|
461
466
|
} finally {
|
|
462
467
|
liveProbes.delete(proc)
|
|
463
468
|
mine?.delete(proc)
|
|
464
469
|
}
|
|
465
|
-
const output = `${stdout}${stderr}`.trim()
|
|
466
470
|
if (exitCode !== 0) {
|
|
471
|
+
const lossy = new TextDecoder()
|
|
472
|
+
const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
|
|
467
473
|
throw new UserError(
|
|
468
474
|
`cache.inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
|
|
469
|
-
(
|
|
475
|
+
(shown ? `\n${shown}` : ''),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
let out: string
|
|
479
|
+
let err: string
|
|
480
|
+
try {
|
|
481
|
+
out = FATAL_UTF8.decode(stdout)
|
|
482
|
+
err = FATAL_UTF8.decode(stderr)
|
|
483
|
+
} catch {
|
|
484
|
+
// A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
|
|
485
|
+
// folded the same output and replayed each other's build.
|
|
486
|
+
throw new UserError(
|
|
487
|
+
`cache.inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
|
|
488
|
+
`Pipe it through a hash or od.`,
|
|
470
489
|
)
|
|
471
490
|
}
|
|
472
|
-
return
|
|
491
|
+
return probeOutput(out, err)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The probe's output as the key folds it. Plain concatenation keyed stdout
|
|
496
|
+
* `ab` and stdout `a` + stderr `b` alike. A probe with no stderr and no NUL
|
|
497
|
+
* folds its trimmed stdout, as before; any other is framed with a leading
|
|
498
|
+
* NUL and stdout's length, a form no unframed output takes.
|
|
499
|
+
*/
|
|
500
|
+
function probeOutput(stdout: string, stderr: string): string {
|
|
501
|
+
const out = stdout.trim()
|
|
502
|
+
const err = stderr.trim()
|
|
503
|
+
if (err === '' && !out.includes('\0')) return out
|
|
504
|
+
return `\0${out.length}\0${out}${err}`
|
|
473
505
|
}
|
|
474
506
|
|
|
475
507
|
/**
|
|
@@ -701,7 +733,7 @@ export async function cleanOutputs(args: {
|
|
|
701
733
|
)
|
|
702
734
|
// Project-relative posix paths of what was removed — the caller
|
|
703
735
|
// feeds these to GitFilesCache.markOutputsChanged after a restore.
|
|
704
|
-
return files.map((f) =>
|
|
736
|
+
return files.map((f) => relPosix(args.projectDir, f))
|
|
705
737
|
}
|
|
706
738
|
|
|
707
739
|
/**
|
|
@@ -839,7 +871,7 @@ const SYNC_CLEAN_MAX = 128
|
|
|
839
871
|
async function removeAll(all: readonly string[], root: string): Promise<string[]> {
|
|
840
872
|
const files = notThroughLink(all, root)
|
|
841
873
|
const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
|
|
842
|
-
const rel =
|
|
874
|
+
const rel = relPosix(root, f)
|
|
843
875
|
return new UserError(
|
|
844
876
|
`cannot remove declared output ${rel}: ${err.code ?? err.message} — vx clears a task's ` +
|
|
845
877
|
`declared outputs before it runs and before a restore; make the path removable ` +
|
|
@@ -1002,7 +1034,7 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1002
1034
|
}): Promise<string[]> {
|
|
1003
1035
|
const files = await resolveWorkspaceOutputs(args)
|
|
1004
1036
|
await pruneEmptiedDirs(args.workspaceRoot, await removeAll(files, args.workspaceRoot))
|
|
1005
|
-
return files.map((f) =>
|
|
1037
|
+
return files.map((f) => relPosix(args.workspaceRoot, f))
|
|
1006
1038
|
}
|
|
1007
1039
|
|
|
1008
1040
|
function stripTrailingSlash(p: string): string {
|
|
@@ -1136,7 +1168,7 @@ function refuseUndecodable(
|
|
|
1136
1168
|
if (undecodable === undefined || undecodable.size === 0) return
|
|
1137
1169
|
const bad = candidates.filter((abs) => undecodable.has(abs) && undecodableOnDisk(abs))
|
|
1138
1170
|
if (bad.length === 0) return
|
|
1139
|
-
const names = bad.map((abs) => JSON.stringify(
|
|
1171
|
+
const names = bad.map((abs) => JSON.stringify(relPosix(root, abs)))
|
|
1140
1172
|
throw new UserError(
|
|
1141
1173
|
`cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
|
|
1142
1174
|
`(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
|
|
@@ -1448,7 +1480,7 @@ function undecodableOnDisk(abs: string): boolean {
|
|
|
1448
1480
|
} catch {
|
|
1449
1481
|
return false
|
|
1450
1482
|
}
|
|
1451
|
-
const lossy = new TextDecoder()
|
|
1483
|
+
const lossy = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
1452
1484
|
for (const name of raw) {
|
|
1453
1485
|
if (lossy.decode(name) !== next) continue
|
|
1454
1486
|
try {
|
|
@@ -1589,7 +1621,7 @@ function addedTo(
|
|
|
1589
1621
|
return undefined
|
|
1590
1622
|
}
|
|
1591
1623
|
|
|
1592
|
-
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
|
|
1624
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
1593
1625
|
|
|
1594
1626
|
/**
|
|
1595
1627
|
* Union of the OUTPUT files matching any positive pattern in `cwd`, minus
|
|
@@ -1676,9 +1708,7 @@ function globFor(pattern: string): Bun.Glob {
|
|
|
1676
1708
|
*/
|
|
1677
1709
|
function inNestedProject(projectDir: string, nestedDirs: string[]): (rel: string) => boolean {
|
|
1678
1710
|
if (nestedDirs.length === 0) return () => false
|
|
1679
|
-
const dirs = new Set(
|
|
1680
|
-
nestedDirs.map((d) => path.relative(projectDir, d).split(path.sep).join('/')),
|
|
1681
|
-
)
|
|
1711
|
+
const dirs = new Set(nestedDirs.map((d) => relPosix(projectDir, d)))
|
|
1682
1712
|
return (rel) => {
|
|
1683
1713
|
for (let i = rel.indexOf('/'); i !== -1; i = rel.indexOf('/', i + 1)) {
|
|
1684
1714
|
if (dirs.has(rel.slice(0, i))) return true
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -38,7 +38,9 @@ const BLOCK = 512
|
|
|
38
38
|
* numbers; a MiB is far past any writer's.
|
|
39
39
|
*/
|
|
40
40
|
const MAX_EXTENDED_HEADER = 1024 * 1024
|
|
41
|
-
|
|
41
|
+
// `ignoreBOM`: a default decoder drops a leading U+FEFF, and a name or
|
|
42
|
+
// prefix field that began with one read back without it.
|
|
43
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
42
44
|
|
|
43
45
|
function field(h: Uint8Array, off: number, len: number): string {
|
|
44
46
|
let end = off
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { Cache } from '../cache/index.js'
|
|
3
|
-
import { flagHint, refusedWord, seeHelp } from './help.js'
|
|
3
|
+
import { flagHint, formatValue, refusedWord, seeHelp } from './help.js'
|
|
4
4
|
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
5
5
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
6
6
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
@@ -125,10 +125,8 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
125
125
|
} else if (a === '--dry-run') {
|
|
126
126
|
out.dryRun = true
|
|
127
127
|
} else if (a === '--format' || a?.startsWith('--format=')) {
|
|
128
|
-
const v = a === '--format' ? args[++i] : a.slice('--format='.length)
|
|
129
|
-
if (v
|
|
130
|
-
return { error: `--format must be pretty or json${seeHelp('cache')}` }
|
|
131
|
-
}
|
|
128
|
+
const v = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'cache')
|
|
129
|
+
if (typeof v === 'object') return v
|
|
132
130
|
out.format = v
|
|
133
131
|
} else {
|
|
134
132
|
const cd = parseCacheDirFlag(args, i)
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// on left a `turbo run --dry-run` user at a dead end. The table renders
|
|
7
7
|
// cli.md's parity section (`renderForeignFlags`), pinned by a test.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
interface ForeignFlag {
|
|
10
10
|
runner: 'turbo' | 'nx'
|
|
11
11
|
/** Every spelling, first the one shown. */
|
|
12
12
|
names: readonly string[]
|
|
@@ -19,6 +19,13 @@ export interface ForeignFlag {
|
|
|
19
19
|
outcome: 'same' | 'alias' | 'refuse'
|
|
20
20
|
/** The vx spelling, or what to use instead. */
|
|
21
21
|
vx: string
|
|
22
|
+
/**
|
|
23
|
+
* The runner reads `=true` / `=false` on it (Turbo's `--force [FORCE]`,
|
|
24
|
+
* Nx's yargs booleans): `=false` is the flag left out, `=true` the flag
|
|
25
|
+
* bare. `--skip-nx-cache=false` forced every task, `--summarize=true`
|
|
26
|
+
* wrote the summary to a file named `true`.
|
|
27
|
+
*/
|
|
28
|
+
bool?: true
|
|
22
29
|
/** For an alias: the vx argv. For a refusal: none. */
|
|
23
30
|
to?: (value: string | undefined) => string[]
|
|
24
31
|
}
|
|
@@ -100,6 +107,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
100
107
|
runner: 'turbo',
|
|
101
108
|
names: ['--force'],
|
|
102
109
|
value: false,
|
|
110
|
+
bool: true,
|
|
103
111
|
outcome: 'same',
|
|
104
112
|
vx: '`--force`: skip cache reads, keep writes',
|
|
105
113
|
},
|
|
@@ -114,6 +122,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
114
122
|
runner: 'turbo',
|
|
115
123
|
names: ['--summarize'],
|
|
116
124
|
value: false,
|
|
125
|
+
bool: true,
|
|
117
126
|
outcome: 'same',
|
|
118
127
|
vx: '`--summarize[=<path>]`',
|
|
119
128
|
},
|
|
@@ -268,6 +277,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
268
277
|
runner: 'turbo',
|
|
269
278
|
names: ['--remote-only'],
|
|
270
279
|
value: false,
|
|
280
|
+
bool: true,
|
|
271
281
|
outcome: 'refuse',
|
|
272
282
|
vx: 'use `--cache local:,remote:rw`',
|
|
273
283
|
},
|
|
@@ -275,6 +285,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
275
285
|
runner: 'turbo',
|
|
276
286
|
names: ['--remote-cache-read-only'],
|
|
277
287
|
value: false,
|
|
288
|
+
bool: true,
|
|
278
289
|
outcome: 'refuse',
|
|
279
290
|
vx: 'use `--cache local:rw,remote:r`',
|
|
280
291
|
},
|
|
@@ -396,8 +407,13 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
396
407
|
names: ['-p', '--projects'],
|
|
397
408
|
value: true,
|
|
398
409
|
outcome: 'alias',
|
|
399
|
-
vx:
|
|
400
|
-
|
|
410
|
+
vx: "`--filter <pattern>`, one per project; a list opening with `!` starts from all (`--filter '*'`)",
|
|
411
|
+
// Nx reads a leading exclusion as "all projects but": `-p '!b,a'` is
|
|
412
|
+
// every project except b, where vx's filters alone would select a.
|
|
413
|
+
to: (v) => {
|
|
414
|
+
const ps = list(v)
|
|
415
|
+
return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => ['--filter', p])
|
|
416
|
+
},
|
|
401
417
|
},
|
|
402
418
|
{
|
|
403
419
|
runner: 'nx',
|
|
@@ -436,6 +452,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
436
452
|
runner: 'nx',
|
|
437
453
|
names: ['--skip-nx-cache'],
|
|
438
454
|
value: false,
|
|
455
|
+
bool: true,
|
|
439
456
|
outcome: 'alias',
|
|
440
457
|
vx: '`--force`',
|
|
441
458
|
to: () => ['--force'],
|
|
@@ -445,6 +462,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
445
462
|
runner: 'nx',
|
|
446
463
|
names: ['--nx-bail'],
|
|
447
464
|
value: false,
|
|
465
|
+
bool: true,
|
|
448
466
|
outcome: 'alias',
|
|
449
467
|
vx: '`--continue=never`',
|
|
450
468
|
to: () => ['--continue=never'],
|
|
@@ -482,6 +500,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
482
500
|
runner: 'nx',
|
|
483
501
|
names: ['--exclude-task-dependencies'],
|
|
484
502
|
value: false,
|
|
503
|
+
bool: true,
|
|
485
504
|
outcome: 'alias',
|
|
486
505
|
vx: '`--exclude-dependencies`',
|
|
487
506
|
to: () => ['--exclude-dependencies'],
|
|
@@ -490,6 +509,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
490
509
|
runner: 'nx',
|
|
491
510
|
names: ['--skip-remote-cache'],
|
|
492
511
|
value: false,
|
|
512
|
+
bool: true,
|
|
493
513
|
outcome: 'alias',
|
|
494
514
|
vx: '`--cache local:rw,remote:`',
|
|
495
515
|
to: () => ['--cache', 'local:rw,remote:'],
|
|
@@ -605,6 +625,13 @@ export function translateForeign(args: readonly string[]): string[] | { error: s
|
|
|
605
625
|
name = kebab
|
|
606
626
|
a = inline === undefined ? kebab : `${kebab}=${inline}`
|
|
607
627
|
}
|
|
628
|
+
if (
|
|
629
|
+
(inline === 'true' || inline === 'false') &&
|
|
630
|
+
FOREIGN_FLAGS.some((f) => f.bool && f.names.includes(name))
|
|
631
|
+
) {
|
|
632
|
+
if (inline === 'false') continue
|
|
633
|
+
a = name
|
|
634
|
+
}
|
|
608
635
|
const entries = FOREIGN_FLAGS.filter((f) => f.names.includes(name) || f.names.includes(a))
|
|
609
636
|
const next = before[i + 1]
|
|
610
637
|
const spaced = inline === undefined && next !== undefined && !next.startsWith('-')
|
|
@@ -627,7 +654,9 @@ export function translateForeign(args: readonly string[]): string[] | { error: s
|
|
|
627
654
|
/** cli.md's parity table, one row per entry. */
|
|
628
655
|
export function renderForeignFlags(): string {
|
|
629
656
|
const rows = FOREIGN_FLAGS.map((f) => {
|
|
630
|
-
const flag =
|
|
657
|
+
const flag =
|
|
658
|
+
f.shown ??
|
|
659
|
+
f.names.map((n) => `\`${n}${f.value ? ' <v>' : f.bool ? '[=<bool>]' : ''}\``).join(', ')
|
|
631
660
|
return `| ${f.runner} | ${flag.replaceAll('|', '\\|')} | ${f.outcome} | ${f.vx.replaceAll('|', '\\|')} |`
|
|
632
661
|
})
|
|
633
662
|
return ['| runner | flag | outcome | in vx |', '| --- | --- | --- | --- |', ...rows].join('\n')
|
package/src/cli/help.ts
CHANGED
|
@@ -110,10 +110,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
110
110
|
' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
111
111
|
' vx lock [--check]',
|
|
112
112
|
' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
|
|
113
|
-
' vx upgrade [
|
|
113
|
+
' vx upgrade [TAG]',
|
|
114
114
|
' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
|
|
115
115
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
116
|
-
' vx why (TASK | PKG#TASK) [--run
|
|
116
|
+
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
117
117
|
' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
|
|
118
118
|
' vx completions bash|zsh|fish',
|
|
119
119
|
' vx help [VERB]',
|
|
@@ -215,16 +215,18 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
215
215
|
' lock status.',
|
|
216
216
|
' --format <fmt> pretty (default) | json.',
|
|
217
217
|
" vx why <task> Why did this task re-run? Compares the task's latest",
|
|
218
|
-
' run (or --run
|
|
218
|
+
' run (or --run RUNID) against its previous run: names the',
|
|
219
219
|
' exact changed cache-key components (files / env / runtime',
|
|
220
220
|
' / upstream) from the persisted input fingerprints.',
|
|
221
221
|
' --format <fmt> pretty (default) | json.',
|
|
222
|
-
" vx last [
|
|
222
|
+
" vx last [RUNID] Replay a recorded run's summary from the local history —",
|
|
223
223
|
' header (command, when, branch, counts) plus the per-task',
|
|
224
224
|
' table, failures first. No re-execution, no cache probe.',
|
|
225
225
|
' --list[=N] List the N most recent runs (default 10) with run ids.',
|
|
226
226
|
' --failed Only failed runs: the latest one, or with --list the list.',
|
|
227
227
|
' --format <fmt> pretty (default) | json.',
|
|
228
|
+
' info, why and last:',
|
|
229
|
+
' --cache-dir <path> Read the cache a run with the same flag uses.',
|
|
228
230
|
'',
|
|
229
231
|
'Migration:',
|
|
230
232
|
' vx init Generate vx.workspace.ts + one vx.config.ts per package',
|
|
@@ -233,6 +235,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
233
235
|
' only vx.workspace.ts, declaring turbo() or nx().',
|
|
234
236
|
' --dry Print the generated files instead of writing them.',
|
|
235
237
|
' --force Overwrite existing vx.config.* files.',
|
|
238
|
+
' --mjs Write .mjs files (no type import) instead of .ts.',
|
|
236
239
|
' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
|
|
237
240
|
' test (executor, cache, telemetry, schedule, admit, commands,',
|
|
238
241
|
' project, graph, key).',
|
|
@@ -267,6 +270,20 @@ export function seeHelp(verb: string): string {
|
|
|
267
270
|
return ` (see \`vx ${verb} --help\`)`
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
/** `--format`'s value, or the one refusal every verb gives for a bad one. */
|
|
274
|
+
export function formatValue(
|
|
275
|
+
v: string | undefined,
|
|
276
|
+
verb: string,
|
|
277
|
+
): 'pretty' | 'json' | { error: string } {
|
|
278
|
+
if (v === 'pretty' || v === 'json') return v
|
|
279
|
+
return {
|
|
280
|
+
error:
|
|
281
|
+
v === undefined || v === ''
|
|
282
|
+
? `--format requires a value: pretty or json${seeHelp(verb)}`
|
|
283
|
+
: `--format must be pretty or json (got ${v})${seeHelp(verb)}`,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
270
287
|
/** How a verb names a word it refuses: a flag, or a positional it takes no more of. */
|
|
271
288
|
export function refusedWord(arg: string): string {
|
|
272
289
|
return arg.startsWith('-') ? 'unknown flag' : 'unexpected argument'
|
|
@@ -339,7 +356,7 @@ export const WATCH_REFUSED_FLAGS: readonly string[] = [
|
|
|
339
356
|
* The flags one verb accepts: those on its own Usage line, and for a verb
|
|
340
357
|
* that takes `[OPTIONS]` (run, watch) the documented run flags — less the
|
|
341
358
|
* ones watch refuses. The Usage line is the verb's synopsis; a section's
|
|
342
|
-
* prose names other verbs' flags (`vx lock --check`, `--run
|
|
359
|
+
* prose names other verbs' flags (`vx lock --check`, `--run RUNID` under
|
|
343
360
|
* `vx why`) and is not read.
|
|
344
361
|
*/
|
|
345
362
|
export function acceptedFlags(verb: string): string[] {
|