@vzn/vx 0.0.591 → 0.0.595

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 (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -41,6 +41,7 @@ import {
41
41
  cacheTodo,
42
42
  pruneOrphanPersistentNotes,
43
43
  } from './migration.js'
44
+ import { relPosix } from '../util/index.js'
44
45
 
45
46
  // `lint` is not here: a linter reads sources, and an edge to `build`
46
47
  // serialises the two for nothing (the init walkthrough, 2026-09-04).
@@ -127,12 +128,26 @@ function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
127
128
  : manager === 'npm'
128
129
  ? !set('ignore-scripts', 'true')
129
130
  : manager === 'pnpm'
130
- ? !set('enable-pre-post-scripts', 'false') &&
131
- !/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
131
+ ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
132
132
  : true
133
133
  return runs ? manager : null
134
134
  }
135
135
 
136
+ /**
137
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
138
+ * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
139
+ * which pnpm 10 honours, and folded hooks pnpm does not run.
140
+ */
141
+ function pnpmSkipsHooks(yaml: string | undefined): boolean {
142
+ if (yaml === undefined) return false
143
+ try {
144
+ const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
145
+ return parsed?.enablePrePostScripts === false
146
+ } catch {
147
+ return false
148
+ }
149
+ }
150
+
136
151
  /** The package manager that owns a directory, and the directory that says so. */
137
152
  interface Owner {
138
153
  manager: string
@@ -155,8 +170,10 @@ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
155
170
  }
156
171
  let manager: string | undefined
157
172
  try {
158
- const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
159
- .packageManager
173
+ // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
174
+ // JSON.parse threw on one and the field went unread.
175
+ const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
176
+ const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
160
177
  // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
161
178
  // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
162
179
  // claim nothing had checked (D-96).
@@ -524,7 +541,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
524
541
  function lernaPackages(dir: string): string[] | undefined {
525
542
  let json: unknown
526
543
  try {
527
- json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
544
+ json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
528
545
  } catch {
529
546
  return undefined
530
547
  }
@@ -789,7 +806,7 @@ export function migrateScripts(
789
806
  } else if (clash !== undefined && outsideDir !== undefined && unnamedMaps().length > 0) {
790
807
  const would = unnamedMaps()
791
808
  notes.push(
792
- `${clash.name} (the workspace root) not mapped: ${path.relative(outsideDir, clash.dir).split(path.sep).join('/')} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
809
+ `${clash.name} (the workspace root) not mapped: ${relPosix(outsideDir, clash.dir)} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
793
810
  )
794
811
  } else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
795
812
  // react's nameless root: "its scripts run the workspace" was not why,
@@ -73,7 +73,7 @@ export function buildPackageGraph(
73
73
  const dev = bucket(p.packageJson.devDependencies)
74
74
  const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
75
75
  if (typeof spec !== 'string') return
76
- const target = linked(key, spec.trim(), p.dir, installed)
76
+ const target = linked(key, fromCatalog(p, key, spec.trim()), p.dir, installed)
77
77
  if (target !== undefined && target !== p.name) into.add(target)
78
78
  }
79
79
  // Which entry is installed when a key sits in more than one field,
@@ -300,6 +300,19 @@ export function buildPackageGraph(
300
300
  }
301
301
  }
302
302
 
303
+ /**
304
+ * The spec a `catalog:` / `catalog:<name>` entry stands for in the
305
+ * workspace's catalog, so it is an edge by the rule its range or protocol
306
+ * meets (zenstack's `nuxt: "catalog:"` is `4.3.1`, which the workspace's
307
+ * versionless `nuxt` sample does not satisfy). A catalog the graph was not
308
+ * given or that lacks the key leaves the spec, which keeps its edge.
309
+ */
310
+ function fromCatalog(p: ProjectMeta, key: string, spec: string): string {
311
+ if (p.catalogs === undefined || !spec.startsWith('catalog:')) return spec
312
+ const resolved = p.catalogs.get(spec.slice('catalog:'.length).trim() || 'default')?.[key]
313
+ return typeof resolved === 'string' && !resolved.startsWith('catalog:') ? resolved.trim() : spec
314
+ }
315
+
303
316
  function bucket(field: unknown): Readonly<Record<string, unknown>> {
304
317
  return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
305
318
  }
@@ -377,8 +390,7 @@ function linkedTarget(
377
390
  if (!MAY_POINT_ELSEWHERE.test(spec)) {
378
391
  const local = byName.get(key)
379
392
  if (local === undefined) return undefined
380
- // A catalog entry's range lives where the graph does not read
381
- // (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
393
+ // A catalog entry its workspace's catalog did not resolve keeps the edge.
382
394
  if (!installed || spec.startsWith('catalog:')) return key
383
395
  return satisfies(local, spec) ? key : undefined
384
396
  }
@@ -269,6 +269,9 @@ interface BuildPosition {
269
269
  column?: number
270
270
  }
271
271
 
272
+ /** A stack frame naming a file path, as a config's own throw has. */
273
+ const NAMED_FRAME = /\n\s+at .*[\\/]/
274
+
272
275
  /**
273
276
  * Turn the two errors Bun's own loader throws into user errors naming the
274
277
  * file the user wrote; every other throw is the config's own and already
@@ -301,6 +304,13 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
301
304
  return configLoadError(errors[0], configPath, kind)
302
305
  }
303
306
  if (typeof message !== 'string') return null
307
+ // Bun's JSON loader throws a `SyntaxError` whose stack, when it has one,
308
+ // holds no file frame: a malformed `import data from './data.json'`
309
+ // printed `vx: JSON Parse error: Expected '}'` and named nothing. A
310
+ // config's own `JSON.parse` has its line in the stack and passes through.
311
+ if (name === 'SyntaxError' && !NAMED_FRAME.test(String((err as { stack?: unknown }).stack))) {
312
+ return new UserError(`${kind} config ${configPath}: an import does not parse: ${message}`)
313
+ }
304
314
  if (name === 'ResolveMessage') {
305
315
  const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
306
316
  const what = spec === undefined ? message.replace(BUST_QUERY, '') : `cannot find '${spec}'`
@@ -469,6 +479,7 @@ export async function loadProjectConfigs(
469
479
  let env: Readonly<Record<string, string | undefined>> = {}
470
480
  let cwd = ''
471
481
  let umask = -1
482
+ let unredirect: (() => void) | undefined
472
483
  // More than one evaluation in flight: a change seen after one load may
473
484
  // be another's.
474
485
  let overlapping = false
@@ -578,6 +589,7 @@ export async function loadProjectConfigs(
578
589
  else misses.push(i)
579
590
  }
580
591
  if (misses.length > 0) {
592
+ unredirect = stdoutToStderr()
581
593
  builtins = builtinSnapshot()
582
594
  env = { ...process.env }
583
595
  cwd = process.cwd()
@@ -641,6 +653,7 @@ export async function loadProjectConfigs(
641
653
  )
642
654
  return results.map((r) => (r as Loaded).config)
643
655
  } finally {
656
+ unredirect?.()
644
657
  endRound()
645
658
  if (store !== undefined && !tainted) {
646
659
  if (evals.length > 0) {
@@ -655,6 +668,72 @@ export async function loadProjectConfigs(
655
668
  }
656
669
  }
657
670
 
671
+ /**
672
+ * Every route to fd 1 sent to stderr while configs evaluate in this
673
+ * process; returns the release. A verb's stdout is its output (`vx show
674
+ * --format json`), and a config's `console.log` came out ahead of the
675
+ * JSON. The worker does the same for a repeat load (D-64), `vx mcp` for
676
+ * its whole serve. Taken before a round's built-in snapshot and released
677
+ * after its check, so the guard sees no change of vx's own.
678
+ *
679
+ * Counted, because rounds overlap (`--affected`'s per-file sweep loads each
680
+ * config in a round of its own): a second install over the first moved
681
+ * `Bun.write` and `console` under the first round's snapshot, which refused
682
+ * the loads, and the undos ran out of order and left a redirect in place
683
+ * for good. The first taker installs; the last release restores.
684
+ */
685
+ let redirectHolders = 0
686
+ let undoRedirect = (): void => {}
687
+ /**
688
+ * Bun's own `Console`, taken at load: the console a round finds may be a
689
+ * constructed one (`vx mcp` serves under its own), which has no `Console`.
690
+ * The global's, not `node:console`'s: the playground bundles this module
691
+ * for the browser, where that specifier is a stub.
692
+ */
693
+ const BunConsole = (globalThis.console as { Console?: typeof console.Console }).Console
694
+
695
+ function stdoutToStderr(): () => void {
696
+ if (redirectHolders++ === 0) undoRedirect = installRedirect()
697
+ return () => {
698
+ if (--redirectHolders === 0) undoRedirect()
699
+ }
700
+ }
701
+
702
+ function installRedirect(): () => void {
703
+ const out = process.stdout
704
+ const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
705
+ const ownConsole = globalThis.console
706
+ const bun = Bun as { write: typeof Bun.write }
707
+ const ownBunWrite = Bun.write
708
+ const ownWriter = Object.getOwnPropertyDescriptor(Bun.stdout, 'writer')
709
+ out.write = ((...args: Parameters<typeof process.stderr.write>) =>
710
+ process.stderr.write(...args)) as typeof out.write
711
+ bun.write = ((dest: unknown, ...rest: unknown[]) =>
712
+ (ownBunWrite as (...a: unknown[]) => Promise<number>)(
713
+ dest === Bun.stdout ? Bun.stderr : dest,
714
+ ...rest,
715
+ )) as typeof Bun.write
716
+ ;(Bun.stdout as { writer: typeof Bun.stdout.writer }).writer = ((
717
+ ...args: Parameters<typeof Bun.stderr.writer>
718
+ ) => Bun.stderr.writer(...args)) as typeof Bun.stdout.writer
719
+ // Bun's console adds `write`, which a constructed Console lacks.
720
+ globalThis.console = Object.assign(new BunConsole!(process.stderr, process.stderr), {
721
+ write: (...data: string[]) => {
722
+ const text = data.join('')
723
+ process.stderr.write(text)
724
+ return text.length
725
+ },
726
+ })
727
+ return () => {
728
+ if (ownWrite === undefined) delete (out as { write?: unknown }).write
729
+ else Object.defineProperty(out, 'write', ownWrite)
730
+ bun.write = ownBunWrite
731
+ if (ownWriter === undefined) delete (Bun.stdout as { writer?: unknown }).writer
732
+ else Object.defineProperty(Bun.stdout, 'writer', ownWriter)
733
+ globalThis.console = ownConsole
734
+ }
735
+ }
736
+
658
737
  /**
659
738
  * The built-in prototypes a config's object is read through. A config that
660
739
  * set `Object.prototype.exec` gave every other project's task that
@@ -665,14 +744,19 @@ export async function loadProjectConfigs(
665
744
  */
666
745
  // `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
667
746
  // => 7n` gave every task the key 00000000, and a changed command replayed
668
- // the old output (D-75).
747
+ // the old output (D-75). `Bun` is read by its identifier, not through
748
+ // globalThis: the docs playground bundles this file with the identifier
749
+ // rewritten to its shim, and a browser has no global `Bun`.
669
750
  const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
670
751
  (name) =>
671
752
  [
672
753
  name,
673
754
  name
674
755
  .split('.')
675
- .reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], globalThis) as object,
756
+ .reduce<unknown>(
757
+ (o, k) => (o === globalThis && k === 'Bun' ? Bun : (o as Record<string, unknown>)[k]),
758
+ globalThis,
759
+ ) as object,
676
760
  ] as const,
677
761
  )
678
762
 
@@ -736,6 +820,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
736
820
  'sleep',
737
821
  'spawn',
738
822
  'spawnSync',
823
+ 'stderr',
739
824
  'stdout',
740
825
  'stringWidth',
741
826
  'stripANSI',
@@ -1024,6 +1109,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1024
1109
  // left out: it loads first in every run, filtered or not, so none
1025
1110
  // depends on what else loaded (D-122's case), and plugins' tests and
1026
1111
  // tools hand state through them.
1112
+ const unredirect = stdoutToStderr()
1027
1113
  const builtins = builtinSnapshot('globalThis', true)
1028
1114
  const env = { ...process.env }
1029
1115
  const cwd = process.cwd()
@@ -1038,6 +1124,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1038
1124
  ...restoreCwd(cwd),
1039
1125
  ...restoreUmask(umask),
1040
1126
  ]
1127
+ unredirect()
1041
1128
  // eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
1042
1129
  if (changed.length > 0) throw builtinsChanged(changed, configPath)
1043
1130
  }
@@ -31,8 +31,18 @@ export interface Workspace {
31
31
  root: string
32
32
  /** Glob patterns relative to root that match project directories. */
33
33
  packageGlobs: string[]
34
+ /** The package manager's catalogs, when the root declares any. */
35
+ catalogs?: Catalogs
34
36
  }
35
37
 
38
+ /**
39
+ * Catalog name → dependency → the spec a `catalog:<name>` entry stands
40
+ * for; `catalog:` alone is `default`. pnpm's from `pnpm-workspace.yaml`
41
+ * (`catalog`, `catalogs`), bun's from the root `package.json` (the same
42
+ * keys, top level or under `workspaces`).
43
+ */
44
+ export type Catalogs = ReadonlyMap<string, Readonly<Record<string, unknown>>>
45
+
36
46
  export interface ProjectMeta {
37
47
  /** Canonical name from package.json. */
38
48
  name: string
@@ -41,6 +51,8 @@ export interface ProjectMeta {
41
51
  packageJson: PackageJson
42
52
  /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
43
53
  configPath: string | null
54
+ /** The workspace's catalogs, which the package graph resolves `catalog:` specs through. */
55
+ catalogs?: Catalogs
44
56
  }
45
57
 
46
58
  /** A discovered project joined with its loaded vx config. */
@@ -115,8 +127,12 @@ export async function findWorkspaceRoot(
115
127
  // them: `packages/**` holding `packages/inner` (with `workspaces:
116
128
  // ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
117
129
  // `inner` to the outer root, two roots and two keys for one tree.
118
- if (claimsMember(dir, [claimed], globs) && claimsMember(dir, members, globs)) claimed = dir
119
- } else if (claimsMember(dir, inner === null ? below : [inner], globs)) {
130
+ if (
131
+ (await claimsMember(dir, [claimed], globs)) &&
132
+ (await claimsMember(dir, members, globs))
133
+ )
134
+ claimed = dir
135
+ } else if (await claimsMember(dir, inner === null ? below : [inner], globs)) {
120
136
  claimed = dir
121
137
  members = inner === null ? [...below] : [inner]
122
138
  }
@@ -152,7 +168,11 @@ export async function findWorkspaceRoot(
152
168
  }
153
169
 
154
170
  /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
155
- function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
171
+ async function claimsMember(
172
+ root: string,
173
+ below: readonly string[],
174
+ globs: readonly string[],
175
+ ): Promise<boolean> {
156
176
  if (below.length === 0 || globs.length === 0) return false
157
177
  const { positive, negative } = splitPackageGlobs(globs)
158
178
  const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
@@ -160,12 +180,25 @@ function claimsMember(root: string, below: readonly string[], globs: readonly st
160
180
  const normalized = pattern.replace(/\/+$/, '')
161
181
  // `.` means the root itself is the project — never a directory below it.
162
182
  if (normalized === '' || normalized === '.') continue
163
- const glob = new Bun.Glob(normalized)
164
- if (rels.some((rel) => glob.match(rel))) return true
183
+ // Matched as discovery scans, on the manifest: `packages/**` lists
184
+ // `packages/package.json`, while against the directory `**` needs a
185
+ // segment below `packages`, and a run from there was its own root.
186
+ const glob = new Bun.Glob(`${normalized}/package.json`)
187
+ for (const rel of rels) {
188
+ if (!glob.match(`${rel}/package.json`)) continue
189
+ // `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
190
+ // which discovery skips, and a run from inside it found a workspace
191
+ // that does not list it. Such a path asks discovery's own walk.
192
+ if (!SKIPPED_SEGMENT.test(rel)) return true
193
+ const dirs = await memberDirs(root, pattern)
194
+ if (dirs.includes(path.join(root, rel))) return true
195
+ }
165
196
  }
166
197
  return false
167
198
  }
168
199
 
200
+ const SKIPPED_SEGMENT = /(^|\/)(\.|node_modules(\/|$))/
201
+
169
202
  /**
170
203
  * pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
171
204
  * list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
@@ -213,17 +246,30 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
213
246
  * the root itself is the only project, hence `['.']`.
214
247
  */
215
248
  async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
249
+ return (await readRootManifests(dir, reads)).globs
250
+ }
251
+
252
+ /** `readPackageGlobs`, and the catalogs the same manifests declare. */
253
+ async function readRootManifests(
254
+ dir: string,
255
+ reads?: LoadReads,
256
+ ): Promise<{ globs: string[] | null; catalogs: Catalogs | undefined }> {
216
257
  const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
217
258
  const yaml = await readOnce(reads, yamlPath)
218
259
  let yamlWithoutPackages = false
260
+ // pnpm reads its catalogs from this file alone, bun from package.json.
261
+ let catalogs: Catalogs | undefined
219
262
  if (yaml !== null) {
220
263
  const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
221
264
  if (parsed !== null && parsed !== undefined) {
222
265
  if (typeof parsed !== 'object' || Array.isArray(parsed)) {
223
266
  throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
224
267
  }
268
+ catalogs = catalogsOf([parsed])
225
269
  const packages = (parsed as { packages?: unknown }).packages
226
- if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
270
+ if (packages !== undefined) {
271
+ return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
272
+ }
227
273
  }
228
274
  // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
229
275
  // for a single-package repo too. The root's package.json decides, as it
@@ -233,18 +279,46 @@ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[
233
279
  }
234
280
  const pkgPath = path.join(dir, 'package.json')
235
281
  const pkgBytes = await readOnce(reads, pkgPath)
236
- if (pkgBytes === null) return yamlWithoutPackages ? [] : null
282
+ if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
237
283
  const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
238
284
  const ws = pkg.workspaces as unknown
239
- if (ws === undefined || ws === null) return ['.']
285
+ if (yaml === null) {
286
+ catalogs = catalogsOf(
287
+ ws !== null && typeof ws === 'object' && !Array.isArray(ws) ? [pkg, ws] : [pkg],
288
+ )
289
+ }
290
+ if (ws === undefined || ws === null) return { globs: ['.'], catalogs }
240
291
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
241
- return assertGlobList(
292
+ const globs = assertGlobList(
242
293
  (ws as { packages?: unknown }).packages ?? [],
243
294
  pkgPath,
244
295
  'workspaces.packages',
245
296
  )
297
+ return { globs, catalogs }
298
+ }
299
+ return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
300
+ }
301
+
302
+ /**
303
+ * The `catalog` and `catalogs` keys of the manifests (bun reads them at
304
+ * the top level and under `workspaces`), or undefined when none declares
305
+ * one. An entry that is not a mapping is no catalog: a `catalog:` spec
306
+ * through it keeps its edge, as one through an unknown catalog does.
307
+ */
308
+ function catalogsOf(manifests: readonly object[]): Catalogs | undefined {
309
+ const isMap = (v: unknown): v is Record<string, unknown> =>
310
+ typeof v === 'object' && v !== null && !Array.isArray(v)
311
+ let out: Map<string, Readonly<Record<string, unknown>>> | undefined
312
+ for (const m of manifests) {
313
+ const { catalog, catalogs } = m as { catalog?: unknown; catalogs?: unknown }
314
+ if (isMap(catalogs)) {
315
+ for (const [name, c] of Object.entries(catalogs))
316
+ if (isMap(c)) (out ??= new Map()).set(name, c)
317
+ }
318
+ // `catalog` is the default catalog to pnpm and bun.
319
+ if (isMap(catalog)) (out ??= new Map()).set('default', catalog)
246
320
  }
247
- return assertGlobList(ws, pkgPath, 'workspaces')
321
+ return out
248
322
  }
249
323
 
250
324
  /**
@@ -374,6 +448,11 @@ function extglobRefusal(pattern: string, file: string, field: string): UserError
374
448
  */
375
449
  export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
376
450
  const rel = config?.cacheDir ?? (process.env['VX_CACHE_DIR'] || path.join('.vx', 'cache'))
451
+ // No shell expands `~` in a config string or a quoted variable, and
452
+ // `'~/.cache/vx'` made a directory named `~` in the workspace.
453
+ if (rel.startsWith('~/')) {
454
+ return path.join(process.env['HOME'] || homedir(), rel.slice(1))
455
+ }
377
456
  return path.resolve(root, rel)
378
457
  }
379
458
 
@@ -412,14 +491,14 @@ const repoIds = new Map<string, Promise<string | null>>()
412
491
  * itself is treated as a single-project workspace.
413
492
  */
414
493
  export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
415
- const packageGlobs = await readPackageGlobs(root, reads)
494
+ const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
416
495
  if (packageGlobs === null) {
417
496
  // The CLI never lands here (findWorkspaceRoot returns only a dir that
418
497
  // passes one of the two checks); a direct call of this public function
419
498
  // on a bare dir does (D-58).
420
499
  throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
421
500
  }
422
- return { root, packageGlobs }
501
+ return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
423
502
  }
424
503
 
425
504
  // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
@@ -697,6 +776,16 @@ export async function discoverProjects(
697
776
  for (const entry of loaded) {
698
777
  if (entry === null) continue
699
778
  const { dir, pkg, configPath } = entry
779
+ // npm and pnpm take `../ext/*`, but `--affected` asks git from the root
780
+ // and sees nothing outside it: an edit there moved the task's key and
781
+ // selected nothing, green.
782
+ const rel = relPosix(workspace.root, dir)
783
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
784
+ throw new UserError(
785
+ `workspace member ${rel} (${dir}) is outside the workspace root ${workspace.root}: ` +
786
+ 'vx keeps every project under the root. Move the workspace root up to a directory that holds every member.',
787
+ )
788
+ }
700
789
  if (!pkg.name) {
701
790
  nameless?.push(dir)
702
791
  // A nameless manifest can't be addressed, filtered, or made affected —
@@ -730,7 +819,7 @@ export async function discoverProjects(
730
819
  }
731
820
  if (group.length === 1) {
732
821
  const { dir, pkg, configPath } = group[0]!
733
- projects.push({ name, dir, packageJson: pkg, configPath })
822
+ projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
734
823
  continue
735
824
  }
736
825
  // The root's own manifest is '' relative to itself, and the refusal read
@@ -809,6 +898,19 @@ export async function namedProject(
809
898
  if (typeof named?.name !== 'string' || named.name === '' || typeof named.dir !== 'string') {
810
899
  throw new UserError(`${where} ${JSON.stringify(named)}: expected { dir: string, name: string }`)
811
900
  }
901
+ // The rules `parsePackageJson` holds a manifest's name to: a plugin's name
902
+ // for a directory with no `name` of its own skipped them, and `a#b` planned
903
+ // under `--all` while no run spec or dependsOn could reach it.
904
+ if (named.name.trim() !== named.name) {
905
+ throw new UserError(
906
+ `${where} ${JSON.stringify(named.name)}: the name has surrounding whitespace`,
907
+ )
908
+ }
909
+ if (named.name.includes('#')) {
910
+ throw new UserError(
911
+ `${where} "${named.name}": the name holds "#" — vx addresses a task as <name>#<task>`,
912
+ )
913
+ }
812
914
  const dir = path.resolve(workspace.root, named.dir)
813
915
  const rel = relPosix(workspace.root, dir)
814
916
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
@@ -848,5 +950,10 @@ export async function namedProject(
848
950
  `${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
849
951
  )
850
952
  }
851
- return { name: named.name, dir, packageJson: pkg, configPath }
953
+ return withCatalogs({ name: named.name, dir, packageJson: pkg, configPath }, workspace)
954
+ }
955
+
956
+ function withCatalogs(meta: ProjectMeta, workspace: Workspace): ProjectMeta {
957
+ if (workspace.catalogs !== undefined) meta.catalogs = workspace.catalogs
958
+ return meta
852
959
  }