@vzn/vx 0.0.591 → 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.
Files changed (55) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +16 -5
  4. package/src/cache/cache.ts +41 -11
  5. package/src/cache/git-inputs.ts +14 -9
  6. package/src/cache/inputs.ts +47 -17
  7. package/src/cache/tar-stream.ts +3 -1
  8. package/src/cli/cache.ts +3 -5
  9. package/src/cli/foreign-flags.ts +33 -4
  10. package/src/cli/help.ts +22 -5
  11. package/src/cli/info.ts +4 -6
  12. package/src/cli/init.ts +3 -3
  13. package/src/cli/last.ts +49 -20
  14. package/src/cli/lock.ts +3 -3
  15. package/src/cli/run.ts +65 -37
  16. package/src/cli/select.ts +14 -2
  17. package/src/cli/show.ts +5 -6
  18. package/src/cli/upgrade.ts +2 -3
  19. package/src/cli/watch-filter.ts +7 -6
  20. package/src/cli/watch-fs.ts +17 -1
  21. package/src/cli/watch.ts +22 -4
  22. package/src/cli/why.ts +4 -13
  23. package/src/cli/workspace-config.ts +1 -1
  24. package/src/exec/env.ts +3 -5
  25. package/src/exec/runner.ts +28 -7
  26. package/src/exec/sandbox-deny-scan.ts +2 -2
  27. package/src/exec/sandbox-runtime.ts +69 -19
  28. package/src/exec/sandbox-violations.ts +23 -9
  29. package/src/graph/scheduler.ts +13 -3
  30. package/src/graph/task-graph.ts +3 -2
  31. package/src/index.ts +3 -0
  32. package/src/orchestrator/affected-tasks.ts +34 -10
  33. package/src/orchestrator/doctor.ts +2 -1
  34. package/src/orchestrator/execute-task.ts +14 -3
  35. package/src/orchestrator/hit-restore.ts +3 -2
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/lockfile-claim.ts +2 -3
  38. package/src/orchestrator/logger.ts +4 -1
  39. package/src/orchestrator/miss-save.ts +10 -3
  40. package/src/orchestrator/placement.ts +17 -3
  41. package/src/orchestrator/plugin-host.ts +16 -0
  42. package/src/orchestrator/plugin.ts +9 -13
  43. package/src/orchestrator/projects.ts +18 -9
  44. package/src/orchestrator/run-context.ts +2 -2
  45. package/src/orchestrator/run-lock.ts +14 -4
  46. package/src/orchestrator/run.ts +42 -8
  47. package/src/orchestrator/sandbox-request.ts +6 -10
  48. package/src/workspace/affected.ts +17 -11
  49. package/src/workspace/config-eval.ts +4 -0
  50. package/src/workspace/config-imports.ts +1 -1
  51. package/src/workspace/config-schema.ts +2 -7
  52. package/src/workspace/filter.ts +5 -4
  53. package/src/workspace/migrate-scripts.ts +22 -5
  54. package/src/workspace/project-loader.ts +17 -2
  55. 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.591",
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.591",
25
- "@vzn/vx-linux-arm64": "0.0.591",
26
- "@vzn/vx-darwin-x64": "0.0.591",
27
- "@vzn/vx-darwin-arm64": "0.0.591"
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"
@@ -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
- this.staged.push({ name, tmp, target, created })
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 first = this.staged[0]
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
- for (const s of this.staged) {
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)
@@ -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). Asked only when there is
460
- * no index yet, so an open cache pays nothing for it.
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
- for (const manifest of ['package.json', 'pnpm-workspace.yaml']) {
464
- if (!existsSync(path.join(cacheDir, manifest))) continue
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 ${manifest}: it is the workspace's or a project's own ` +
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. Point \`cacheDir\` in vx.workspace.ts (or ` +
469
- `--cache-dir) at a directory of its own, such as .vx/cache.`,
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 })
@@ -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 = path.relative(at, real).split(path.sep).join('/')
121
+ const below = relPosix(at, real)
119
122
  return {
120
123
  prefix: below === '' ? '' : `${below}/`,
121
- commonDir: path.relative(real, dotGit).split(path.sep).join('/'),
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(path.relative(key, abs).split(path.sep).join('/'))
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
- const LOSSY_UTF8 = new TextDecoder()
431
- const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
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) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
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
- : path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
1563
+ : relPosix(workspaceRoot, projectDir)
1559
1564
  if (relPrefix === '' || relPrefix === '.') {
1560
1565
  cache.set(projectDir, all)
1561
1566
  const rootOids = new Map<string, string>()
@@ -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
- const prefix = binDirs.join(path.delimiter)
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: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
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).text(),
458
- new Response(proc.stderr).text(),
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
- (output ? `\n${output}` : ''),
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 output
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) => path.relative(args.projectDir, f).split(path.sep).join('/'))
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 = path.relative(root, f).split(path.sep).join('/')
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) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
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(path.relative(root, abs).split(path.sep).join('/')))
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
@@ -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
- const decoder = new TextDecoder()
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 !== 'pretty' && v !== 'json') {
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)
@@ -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
- export interface ForeignFlag {
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: '`--filter <pattern>`, one per project',
400
- to: (v) => list(v).flatMap((p) => ['--filter', p]),
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 = f.shown ?? f.names.map((n) => `\`${n}${f.value ? ' <v>' : ''}\``).join(', ')
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 [tag]',
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 <runId>] [--format pretty|json] [--cache-dir <path>]',
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 <id>) against its previous run: names the',
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 [runId] Replay a recorded run's summary from the local history —",
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 <id>` under
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[] {