@vzn/vx 0.0.594 → 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 (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
@@ -28,7 +28,7 @@ import {
28
28
  asTrees,
29
29
  isLiteralPattern,
30
30
  } from '../util/index.js'
31
- import { compileNameGlob } from './filter.js'
31
+ import { compileNameGlob, parseFilter } from './filter.js'
32
32
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
33
33
 
34
34
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
@@ -78,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
78
78
 
79
79
  function validateRetention(retention: unknown, configPath: string): void {
80
80
  const where = `${configPath}: \`cacheRetention\``
81
+ const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
81
82
  if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
82
83
  throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
83
84
  }
@@ -91,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
91
92
  olderThan !== undefined &&
92
93
  (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
93
94
  ) {
94
- throw new UserError(`${where}.olderThan must be a duration like '30d', '12h', '90m' or '45s'`)
95
+ throw new UserError(
96
+ `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
97
+ )
95
98
  }
96
99
  if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
97
- throw new UserError(`${where}.maxSize must be a size like '10G', '500MB' or '64KB'`)
100
+ throw new UserError(
101
+ `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
102
+ )
98
103
  }
99
104
  // The bounds `vx cache prune` refuses, for the same reason: each evicts
100
105
  // every entry, and here it would do so at the end of every run, the
101
106
  // entries that run just saved included, so nothing ever hits (item 969).
102
107
  if (olderThan !== undefined && parseDuration(olderThan) === 0) {
103
- throw new UserError(`${where}.olderThan of 0 evicts every entry after every run`)
108
+ throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
104
109
  }
105
110
  if (maxSize !== undefined && parseSize(maxSize) === 0) {
106
- throw new UserError(`${where}.maxSize of 0 evicts every entry after every run`)
111
+ throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
107
112
  }
108
113
  if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
109
114
  throw new UserError(
110
- `${where}.maxSize '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
115
+ `${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
111
116
  )
112
117
  }
113
118
  }
@@ -230,6 +235,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
230
235
  if (plug.commands === null || typeof plug.commands !== 'object') {
231
236
  throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
232
237
  }
238
+ // A list of commands loaded as the verbs `0`, `1`, … that help listed.
239
+ if (Array.isArray(plug.commands)) {
240
+ throw new UserError(
241
+ `${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
242
+ )
243
+ }
233
244
  for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
234
245
  const c = cmd as { description?: unknown; run?: unknown } | null
235
246
  if (
@@ -345,6 +356,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
345
356
  ) {
346
357
  throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
347
358
  }
359
+ for (const tag of (tags ?? []) as string[]) {
360
+ const why = tagProblem(tag)
361
+ if (why !== null) {
362
+ throw new UserError(
363
+ `${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
364
+ `name it. Rename the tag.`,
365
+ )
366
+ }
367
+ }
348
368
  const tasks = config.tasks
349
369
  if (tasks === undefined) return
350
370
  if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
@@ -1367,6 +1387,22 @@ export function taskNameProblem(name: string): string | null {
1367
1387
  : null
1368
1388
  }
1369
1389
 
1390
+ /**
1391
+ * Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
1392
+ * parser itself, so a suffix it learns to read is refused here too: `v1...`
1393
+ * read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
1394
+ */
1395
+ function tagProblem(tag: string): string | null {
1396
+ if (tag.trim() !== tag) return 'has surrounding whitespace'
1397
+ if (tag.includes('*')) return "holds '*', which makes it a pattern"
1398
+ const f = parseFilter(`tag:${tag}`, '/')
1399
+ if (f.gitSince !== undefined) {
1400
+ return `ends in "[${f.gitSince}]", which a filter reads as a git range`
1401
+ }
1402
+ if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
1403
+ return null
1404
+ }
1405
+
1370
1406
  /**
1371
1407
  * Refuse a non-empty glob list made up ENTIRELY of negations.
1372
1408
  *
@@ -1488,6 +1524,12 @@ function assertStringArray(v: unknown, where: string): void {
1488
1524
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
1489
1525
  throw new UserError(`${where} must be an array of non-empty strings`)
1490
1526
  }
1527
+ // No path, host or name holds a NUL; a write grant with one reached the
1528
+ // placeholder create as an internal error.
1529
+ const nul = (v as string[]).find((s) => s.includes('\0'))
1530
+ if (nul !== undefined) {
1531
+ throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
1532
+ }
1491
1533
  }
1492
1534
 
1493
1535
  /**
@@ -541,7 +541,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
541
541
  function lernaPackages(dir: string): string[] | undefined {
542
542
  let json: unknown
543
543
  try {
544
- json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
544
+ json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
545
545
  } catch {
546
546
  return undefined
547
547
  }
@@ -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
  }
@@ -479,6 +479,7 @@ export async function loadProjectConfigs(
479
479
  let env: Readonly<Record<string, string | undefined>> = {}
480
480
  let cwd = ''
481
481
  let umask = -1
482
+ let unredirect: (() => void) | undefined
482
483
  // More than one evaluation in flight: a change seen after one load may
483
484
  // be another's.
484
485
  let overlapping = false
@@ -588,6 +589,7 @@ export async function loadProjectConfigs(
588
589
  else misses.push(i)
589
590
  }
590
591
  if (misses.length > 0) {
592
+ unredirect = stdoutToStderr()
591
593
  builtins = builtinSnapshot()
592
594
  env = { ...process.env }
593
595
  cwd = process.cwd()
@@ -651,6 +653,7 @@ export async function loadProjectConfigs(
651
653
  )
652
654
  return results.map((r) => (r as Loaded).config)
653
655
  } finally {
656
+ unredirect?.()
654
657
  endRound()
655
658
  if (store !== undefined && !tainted) {
656
659
  if (evals.length > 0) {
@@ -665,6 +668,72 @@ export async function loadProjectConfigs(
665
668
  }
666
669
  }
667
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
+
668
737
  /**
669
738
  * The built-in prototypes a config's object is read through. A config that
670
739
  * set `Object.prototype.exec` gave every other project's task that
@@ -751,6 +820,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
751
820
  'sleep',
752
821
  'spawn',
753
822
  'spawnSync',
823
+ 'stderr',
754
824
  'stdout',
755
825
  'stringWidth',
756
826
  'stripANSI',
@@ -1039,6 +1109,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1039
1109
  // left out: it loads first in every run, filtered or not, so none
1040
1110
  // depends on what else loaded (D-122's case), and plugins' tests and
1041
1111
  // tools hand state through them.
1112
+ const unredirect = stdoutToStderr()
1042
1113
  const builtins = builtinSnapshot('globalThis', true)
1043
1114
  const env = { ...process.env }
1044
1115
  const cwd = process.cwd()
@@ -1053,6 +1124,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1053
1124
  ...restoreCwd(cwd),
1054
1125
  ...restoreUmask(umask),
1055
1126
  ]
1127
+ unredirect()
1056
1128
  // eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
1057
1129
  if (changed.length > 0) throw builtinsChanged(changed, configPath)
1058
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. */
@@ -168,9 +180,12 @@ async function claimsMember(
168
180
  const normalized = pattern.replace(/\/+$/, '')
169
181
  // `.` means the root itself is the project — never a directory below it.
170
182
  if (normalized === '' || normalized === '.') continue
171
- const glob = new Bun.Glob(normalized)
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`)
172
187
  for (const rel of rels) {
173
- if (!glob.match(rel)) continue
188
+ if (!glob.match(`${rel}/package.json`)) continue
174
189
  // `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
175
190
  // which discovery skips, and a run from inside it found a workspace
176
191
  // that does not list it. Such a path asks discovery's own walk.
@@ -231,17 +246,30 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
231
246
  * the root itself is the only project, hence `['.']`.
232
247
  */
233
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 }> {
234
257
  const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
235
258
  const yaml = await readOnce(reads, yamlPath)
236
259
  let yamlWithoutPackages = false
260
+ // pnpm reads its catalogs from this file alone, bun from package.json.
261
+ let catalogs: Catalogs | undefined
237
262
  if (yaml !== null) {
238
263
  const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
239
264
  if (parsed !== null && parsed !== undefined) {
240
265
  if (typeof parsed !== 'object' || Array.isArray(parsed)) {
241
266
  throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
242
267
  }
268
+ catalogs = catalogsOf([parsed])
243
269
  const packages = (parsed as { packages?: unknown }).packages
244
- if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
270
+ if (packages !== undefined) {
271
+ return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
272
+ }
245
273
  }
246
274
  // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
247
275
  // for a single-package repo too. The root's package.json decides, as it
@@ -251,18 +279,46 @@ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[
251
279
  }
252
280
  const pkgPath = path.join(dir, 'package.json')
253
281
  const pkgBytes = await readOnce(reads, pkgPath)
254
- if (pkgBytes === null) return yamlWithoutPackages ? [] : null
282
+ if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
255
283
  const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
256
284
  const ws = pkg.workspaces as unknown
257
- 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 }
258
291
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
259
- return assertGlobList(
292
+ const globs = assertGlobList(
260
293
  (ws as { packages?: unknown }).packages ?? [],
261
294
  pkgPath,
262
295
  'workspaces.packages',
263
296
  )
297
+ return { globs, catalogs }
264
298
  }
265
- return assertGlobList(ws, pkgPath, 'workspaces')
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)
320
+ }
321
+ return out
266
322
  }
267
323
 
268
324
  /**
@@ -435,14 +491,14 @@ const repoIds = new Map<string, Promise<string | null>>()
435
491
  * itself is treated as a single-project workspace.
436
492
  */
437
493
  export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
438
- const packageGlobs = await readPackageGlobs(root, reads)
494
+ const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
439
495
  if (packageGlobs === null) {
440
496
  // The CLI never lands here (findWorkspaceRoot returns only a dir that
441
497
  // passes one of the two checks); a direct call of this public function
442
498
  // on a bare dir does (D-58).
443
499
  throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
444
500
  }
445
- return { root, packageGlobs }
501
+ return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
446
502
  }
447
503
 
448
504
  // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
@@ -720,6 +776,16 @@ export async function discoverProjects(
720
776
  for (const entry of loaded) {
721
777
  if (entry === null) continue
722
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
+ }
723
789
  if (!pkg.name) {
724
790
  nameless?.push(dir)
725
791
  // A nameless manifest can't be addressed, filtered, or made affected —
@@ -753,7 +819,7 @@ export async function discoverProjects(
753
819
  }
754
820
  if (group.length === 1) {
755
821
  const { dir, pkg, configPath } = group[0]!
756
- projects.push({ name, dir, packageJson: pkg, configPath })
822
+ projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
757
823
  continue
758
824
  }
759
825
  // The root's own manifest is '' relative to itself, and the refusal read
@@ -884,5 +950,10 @@ export async function namedProject(
884
950
  `${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
885
951
  )
886
952
  }
887
- 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
888
959
  }