@vzn/vx-migrate 0.0.635 → 0.0.637

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.
@@ -0,0 +1,806 @@
1
+ // Package-manager workspaces: the members `package.json#workspaces` or
2
+ // `pnpm-workspace.yaml` list, and the root a migration writes
3
+ // `.vx/workspace.ts` at. Core reads neither (M, 2026-10-14): a project is a
4
+ // directory holding a vx.config.
5
+ import { constants, existsSync, type Dirent } from 'node:fs'
6
+ import { access, readdir, realpath, stat } from 'node:fs/promises'
7
+ import path from 'node:path'
8
+ import { PROJECT_CONFIG_FILENAMES, UserError } from '@vzn/vx'
9
+ import {
10
+ BUN_GLOB_WILDCARDS,
11
+ EXTGLOB,
12
+ gitTop,
13
+ isOutOfFds,
14
+ isPermissionError,
15
+ type LoadReads,
16
+ normalizeBunGlob,
17
+ readOnce,
18
+ realpathOf,
19
+ slashBraceExpansions,
20
+ unreadable,
21
+ } from './package-fs.js'
22
+ import { relPosix } from './paths.js'
23
+
24
+ export interface PackageJson {
25
+ name: string
26
+ version?: string
27
+ dependencies?: Record<string, string>
28
+ devDependencies?: Record<string, string>
29
+ peerDependencies?: Record<string, string>
30
+ optionalDependencies?: Record<string, string>
31
+ /** npm / yarn / bun workspaces. May be a glob array or { packages: [...] }. */
32
+ workspaces?: string[] | { packages?: string[] }
33
+ }
34
+
35
+ export interface PackageWorkspace {
36
+ root: string
37
+ /** Glob patterns relative to root that match member directories. */
38
+ packageGlobs: string[]
39
+ }
40
+
41
+ /** A workspace member: its `package.json#name`, directory, manifest and vx config. */
42
+ export interface PackageMeta {
43
+ name: string
44
+ /** Absolute path to the package directory. */
45
+ dir: string
46
+ packageJson: PackageJson
47
+ /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
48
+ configPath: string | null
49
+ }
50
+
51
+ const decoder = new TextDecoder()
52
+
53
+ /**
54
+ * Where `@vzn/vx-migrate` (and so `vx init`) puts `.vx/workspace.ts` when
55
+ * there is none yet: the package manager's workspace root. A directory is a root
56
+ * CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
57
+ *
58
+ * The nearest candidate that CLAIMS `start` wins — one of the directories
59
+ * between it and `start` matches one of its package globs. Every workspace
60
+ * member has its own `package.json`, so stopping at the first candidate would
61
+ * make a run from inside a package treat that package as the whole workspace:
62
+ * `^task` edges vanish, upstream hashes drop out of the cache key (stale
63
+ * hits), and a second cache dir appears under the member. Claiming is decided
64
+ * with the same globs `loadPackageWorkspace` applies, so "the root that claims me"
65
+ * and "the root that lists me as a project" cannot diverge. An outer root
66
+ * that lists both the claimer and the claimed member outranks the claimer:
67
+ * from the claimer's own directory the walk reaches the outer root too.
68
+ *
69
+ * When no candidate claims `start` — a standalone package, or a subdirectory
70
+ * of a single-project repo — the nearest candidate wins (the root itself IS
71
+ * the project). Throws a `UserError` when there is no candidate before `/`.
72
+ * A load that goes on to `loadPackageWorkspace` passes its `reads`, so the
73
+ * root's manifest is read once for both.
74
+ *
75
+ * Inside git the walk never goes above the git top-level ({@link gitTop}),
76
+ * so a manifest above the checkout is never even read: a `package.json`
77
+ * planted in a shared directory above it claimed it, and its workspace
78
+ * config ran as you (owner, 2026-10-13: "find the root based on git").
79
+ *
80
+ * Outside git the walk stops above `start` at a manifest another user
81
+ * owns (root aside), the same case without a top-level to stop at
82
+ * (git's `safe.directory`). The directory you run in is yours to
83
+ * judge, and costs no stat. Run as root, vx trusts every owner, as a
84
+ * container over a mounted checkout needs. `uid` is for tests.
85
+ */
86
+ export async function findManifestRoot(
87
+ start: string,
88
+ reads: LoadReads = new Map(),
89
+ uid: number = process.getuid?.() ?? 0,
90
+ ): Promise<string> {
91
+ start = path.resolve(start)
92
+ const top = await gitTop(start)
93
+ let dir = start
94
+ const below: string[] = []
95
+ let nearest: string | null = null
96
+ // The nearest manifest with workspace globs of its own, once passed: an
97
+ // outer root claims the tree only by listing this directory itself.
98
+ let inner: string | null = null
99
+ // The root that claims `start`, and the members it claimed it through.
100
+ let claimed: string | null = null
101
+ let members: string[] = []
102
+ let foreign: string | null = null
103
+ while (true) {
104
+ let globs: string[] | null
105
+ try {
106
+ globs = await readPackageGlobs(dir, reads)
107
+ } catch (err) {
108
+ if (isOutOfFds(err)) throw err
109
+ // An unparseable manifest is still a root SIGNAL (the pre-existing
110
+ // behaviour probed only for existence); it just can't claim members.
111
+ // `loadPackageWorkspace` surfaces the parse error if this dir is chosen.
112
+ globs = []
113
+ }
114
+ if (globs !== null && top === null && uid !== 0 && dir !== start) {
115
+ const owner = await foreignOwner(dir, uid, reads)
116
+ if (owner !== null) {
117
+ foreign = owner
118
+ break
119
+ }
120
+ }
121
+ if (globs !== null) {
122
+ nearest ??= dir
123
+ if (claimed !== null) {
124
+ // An outer root that lists both the claimer and the member owns
125
+ // them: `packages/**` holding `packages/inner` (with `workspaces:
126
+ // ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
127
+ // `inner` to the outer root, two roots and two keys for one tree.
128
+ if (
129
+ (await claimsMember(dir, [claimed], globs)) &&
130
+ (await claimsMember(dir, members, globs))
131
+ )
132
+ claimed = dir
133
+ } else if (await claimsMember(dir, inner === null ? below : [inner], globs)) {
134
+ claimed = dir
135
+ members = inner === null ? [...below] : [inner]
136
+ }
137
+ // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
138
+ // not. Walking past it, `apps/inner` resolved to the outer workspace
139
+ // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
140
+ // two caches for one tree (item 990). Already read: `reads` holds it.
141
+ if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
142
+ return claimed ?? dir
143
+ }
144
+ }
145
+ const parent = path.dirname(dir)
146
+ if (parent === dir || dir === top) break
147
+ // Only a directory holding a manifest can be a member: a glob that
148
+ // matched `packages/tools`, which has none, claimed the standalone
149
+ // package below it, and `packages/tools/standalone` ran as a stranger
150
+ // in a workspace that does not list it (item 989).
151
+ if (globs !== null) below.push(dir)
152
+ // `apps/tool/workspace` with its own `workspaces`, under a root listing
153
+ // `apps/*`, resolved to the outer root through `apps/tool`, and its
154
+ // members ran in a workspace that does not list them (D-137). npm makes
155
+ // an outer root own a nested one only when it lists that directory.
156
+ if (inner === null && globs !== null && globs.length > 0) inner = dir
157
+ dir = parent
158
+ }
159
+ if (claimed !== null) return claimed
160
+ if (nearest !== null) return nearest
161
+ if (foreign !== null) {
162
+ throw new UserError(
163
+ `${foreign} belongs to another user, so vx does not take its directory as your workspace: ` +
164
+ `its config would run as you. Run vx in a checkout you own, or create a package.json there`,
165
+ 'VX_E_NO_WORKSPACE',
166
+ )
167
+ }
168
+ throw new UserError(
169
+ `no package.json or pnpm-workspace.yaml in ${start} or any parent, so nothing to map: ` +
170
+ `\`vx init\` sets up a workspace without one`,
171
+ 'VX_E_NO_WORKSPACE',
172
+ )
173
+ }
174
+
175
+ /** The first root manifest in `dir` another non-root user owns, or null. */
176
+ async function foreignOwner(dir: string, uid: number, reads: LoadReads): Promise<string | null> {
177
+ for (const name of ['package.json', 'pnpm-workspace.yaml']) {
178
+ const file = path.join(dir, name)
179
+ // Already read by the walk: only a manifest that is there costs a stat.
180
+ if ((await readOnce(reads, file)) === null) continue
181
+ const owner = await stat(file).then(
182
+ (st) => st.uid,
183
+ () => uid,
184
+ )
185
+ if (owner !== uid && owner !== 0) return file
186
+ }
187
+ return null
188
+ }
189
+
190
+ /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
191
+ async function claimsMember(
192
+ root: string,
193
+ below: readonly string[],
194
+ globs: readonly string[],
195
+ ): Promise<boolean> {
196
+ if (below.length === 0 || globs.length === 0) return false
197
+ const { positive, negative } = splitPackageGlobs(globs)
198
+ const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
199
+ for (const pattern of positive) {
200
+ const normalized = pattern.replace(/\/+$/, '')
201
+ // `.` means the root itself is the project — never a directory below it.
202
+ if (normalized === '' || normalized === '.') continue
203
+ // Matched as discovery scans, on the manifest: `packages/**` lists
204
+ // `packages/package.json`, while against the directory `**` needs a
205
+ // segment below `packages`, and a run from there was its own root.
206
+ const glob = new Bun.Glob(`${normalized}/package.json`)
207
+ for (const rel of rels) {
208
+ if (!glob.match(`${rel}/package.json`)) continue
209
+ // `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
210
+ // which discovery skips, and a run from inside it found a workspace
211
+ // that does not list it. Such a path asks discovery's own walk.
212
+ if (!SKIPPED_SEGMENT.test(rel)) return true
213
+ const dirs = await memberDirs(root, pattern)
214
+ if (dirs.includes(path.join(root, rel))) return true
215
+ }
216
+ }
217
+ return false
218
+ }
219
+
220
+ const SKIPPED_SEGMENT = /(^|\/)(\.|node_modules(\/|$))/
221
+
222
+ /**
223
+ * pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
224
+ * list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
225
+ * so `!packages/fixtures/package.json` matched every manifest in the tree
226
+ * and every directory holding one became a project — the excluded package
227
+ * ran under `--all`, and a fixture repeating a name killed the run with
228
+ * "Duplicate package name". Negations subtract from what the positive
229
+ * globs found; a literal one excludes its tree.
230
+ */
231
+ function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
232
+ const positive: string[] = []
233
+ const negative: string[] = []
234
+ // `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
235
+ // (2026-09-10): the same spellings the input globs normalize.
236
+ for (const raw of globs) {
237
+ // A member glob names directories, so a trailing slash says nothing:
238
+ // npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
239
+ // gives it a task glob's meaning, `/**`, and `packages/*/` found every
240
+ // example and fixture package at any depth below the members (item 985).
241
+ const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
242
+ if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
243
+ else positive.push(g)
244
+ }
245
+ return { positive, negative }
246
+ }
247
+
248
+ function excludedBy(rel: string, negative: readonly string[]): boolean {
249
+ for (const neg of negative) {
250
+ if (neg.length === 0) continue
251
+ if (rel === neg || rel.startsWith(`${neg}/`)) return true
252
+ if (!BUN_GLOB_WILDCARDS.test(neg)) continue
253
+ if (new Bun.Glob(neg).match(rel)) return true
254
+ // pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
255
+ // `!**/test/**` excludes `packages/test` itself; matched against the
256
+ // directory, `**/test/**` needs something below `test` and the package
257
+ // stayed a member (item 986).
258
+ if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
259
+ }
260
+ return false
261
+ }
262
+
263
+ /**
264
+ * Package globs declared by `dir`, or `null` when `dir` is not a root
265
+ * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
266
+ * the root itself is the only project, hence `['.']`.
267
+ */
268
+ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
269
+ const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
270
+ const yaml = await readOnce(reads, yamlPath)
271
+ let yamlWithoutPackages = false
272
+ if (yaml !== null) {
273
+ const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
274
+ if (parsed !== null && parsed !== undefined) {
275
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
276
+ throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
277
+ }
278
+ const packages = (parsed as { packages?: unknown }).packages
279
+ // A list commented out leaves `packages:` null, which pnpm reads as
280
+ // absent; vx refused it as "must be an array" (D-149).
281
+ if (packages !== undefined && packages !== null) {
282
+ return assertGlobList(packages, yamlPath, 'packages')
283
+ }
284
+ }
285
+ // No `packages:` (or an empty one): pnpm 10 keeps its settings and catalogs in this file
286
+ // for a single-package repo too. The root's package.json decides, as it
287
+ // would without the file; read as an empty list, the root found zero
288
+ // projects and every verb ran nothing and exited 0 (item 984).
289
+ yamlWithoutPackages = true
290
+ }
291
+ const pkgPath = path.join(dir, 'package.json')
292
+ const pkgBytes = await readOnce(reads, pkgPath)
293
+ if (pkgBytes === null) return yamlWithoutPackages ? [] : null
294
+ const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
295
+ const ws = pkg.workspaces as unknown
296
+ if (ws === undefined || ws === null) return ['.']
297
+ // bun's `{ catalog }` and yarn's `{ nohoist }` name no members, and both
298
+ // managers run the root alone; vx refused them as no array (D-151).
299
+ if (typeof ws === 'object' && !Array.isArray(ws) && !('packages' in ws)) return ['.']
300
+ if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
301
+ const packages = (ws as { packages?: unknown }).packages
302
+ // Yarn 1 and 4 run the root alone under `packages: null`; read as an
303
+ // empty list, the root was no project and every verb ran nothing (D-150).
304
+ if (packages === null) return ['.']
305
+ return assertGlobList(packages, pkgPath, 'workspaces.packages')
306
+ }
307
+ return assertGlobList(ws, pkgPath, 'workspaces')
308
+ }
309
+
310
+ /**
311
+ * Package manifests the globs never reach, in single-project mode only: a
312
+ * root `package.json` with no `workspaces` field makes the root the one
313
+ * project, and a `packages/app/package.json` full of scripts beside it is
314
+ * then invisible — `vx init` said "no package.json scripts" and `vx run`
315
+ * said "run vx init" (2026-09-16). One shallow scan, two levels, on the
316
+ * failure path only; `node_modules` and dot directories are skipped.
317
+ */
318
+ export async function unreachedPackages(workspace: PackageWorkspace): Promise<string[]> {
319
+ if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
320
+ const found: string[] = []
321
+ // Two scans: Bun.Glob does not expand a brace whose alternatives hold a
322
+ // slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
323
+ for (const pattern of ['*/package.json', '*/*/package.json']) {
324
+ for await (const rel of new Bun.Glob(pattern).scan({
325
+ cwd: workspace.root,
326
+ onlyFiles: true,
327
+ dot: false,
328
+ })) {
329
+ const posix = rel.split(path.sep).join('/')
330
+ if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
331
+ found.push(posix.slice(0, -'/package.json'.length))
332
+ }
333
+ }
334
+ return found.sort()
335
+ }
336
+
337
+ /**
338
+ * The line for `unreachedPackages`' finding: the cause, the packages, the
339
+ * glob to add, and where. A `pnpm-workspace.yaml` with no `packages:` (pnpm
340
+ * keeps its settings there) is where pnpm reads the globs: the hint said
341
+ * there was no such file and named package.json, beside ngrx's (2026-10-07).
342
+ */
343
+ export function unreachedHint(unreached: readonly string[], root: string): string {
344
+ const n = unreached.length
345
+ const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
346
+ const globs = [
347
+ ...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
348
+ ]
349
+ .map((g) => `"${g}"`)
350
+ .join(', ')
351
+ const rest = `so the root is the only project and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}.`
352
+ return existsSync(path.join(root, 'pnpm-workspace.yaml'))
353
+ ? `pnpm-workspace.yaml lists no \`packages\`, ${rest} Add \`packages: [${globs}]\` to pnpm-workspace.yaml and re-run.`
354
+ : `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), ${rest} ` +
355
+ `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
356
+ }
357
+
358
+ /**
359
+ * Parse a workspace manifest, naming the FILE on failure. The raw parser
360
+ * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
361
+ * monorepo they say nothing about which manifest is broken.
362
+ */
363
+ function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
364
+ try {
365
+ return parse(text)
366
+ } catch (err) {
367
+ const msg = err instanceof Error ? err.message : String(err)
368
+ throw new UserError(`failed to parse ${file}: ${msg}`)
369
+ }
370
+ }
371
+
372
+ /**
373
+ * A `package.json` as discovery reads it: a JSON object whose `name`, when
374
+ * present, is a string with no surrounding whitespace (npm refuses one).
375
+ * Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
376
+ * `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
377
+ * projects (item 988).
378
+ */
379
+ function parsePackageJson(text: string, file: string): PackageJson {
380
+ const pkg = parseManifest(text, file, JSON.parse)
381
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
382
+ throw new UserError(`${file}: must be a JSON object`)
383
+ }
384
+ const name = (pkg as { name?: unknown }).name
385
+ if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
386
+ throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
387
+ }
388
+ // A task is `<project>#<task>` split at the first `#`: `"a#b"` planned
389
+ // under `--all` but no run spec or dependsOn could reach it (D-130).
390
+ if (typeof name === 'string' && name.includes('#')) {
391
+ throw new UserError(`${file}: "name" cannot hold "#" — vx addresses a task as <name>#<task>`)
392
+ }
393
+ return pkg as PackageJson
394
+ }
395
+
396
+ /**
397
+ * A glob list is user input from a manifest: `packages: "packages/*"`
398
+ * (a bare string instead of a list) is an easy YAML slip, and without
399
+ * this it surfaces as `workspace.packageGlobs.map is not a function`.
400
+ */
401
+ function assertGlobList(value: unknown, file: string, field: string): string[] {
402
+ if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
403
+ throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
404
+ }
405
+ for (const pattern of value as string[]) {
406
+ if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
407
+ }
408
+ return value as string[]
409
+ }
410
+
411
+ function extglobRefusal(pattern: string, file: string, field: string): UserError {
412
+ const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
413
+ // The common shape, a whole segment excluding plain names, has an exact
414
+ // rewrite in the `!` entries every package manager takes.
415
+ const segments = pattern.split('/')
416
+ const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
417
+ const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
418
+ const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
419
+ const rest = segments.filter((_s, i) => i !== at).join('/')
420
+ if (at === -1 || !plain || EXTGLOB.test(rest)) {
421
+ return new UserError(
422
+ `${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
423
+ )
424
+ }
425
+ const before = segments.slice(0, at)
426
+ const rewrite = [
427
+ [...before, '*', ...segments.slice(at + 1)].join('/'),
428
+ ...names.map((n) => `!${[...before, n].join('/')}`),
429
+ ]
430
+ return new UserError(
431
+ `${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
432
+ )
433
+ }
434
+
435
+ /**
436
+ * Read the workspace's package-glob list, supporting all common
437
+ * package managers:
438
+ * - `pnpm-workspace.yaml` (pnpm)
439
+ * - `package.json` `workspaces` array (npm / yarn / bun)
440
+ * - `package.json` `workspaces.packages` array (yarn legacy)
441
+ *
442
+ * If a `package.json` exists with no `workspaces` field, the root
443
+ * itself is treated as a single-project workspace.
444
+ */
445
+ export async function loadPackageWorkspace(
446
+ root: string,
447
+ reads?: LoadReads,
448
+ ): Promise<PackageWorkspace> {
449
+ const packageGlobs = await readPackageGlobs(root, reads)
450
+ if (packageGlobs === null) {
451
+ // A migration asks only of a root findManifestRoot found, which holds
452
+ // one; a direct call of this public function on a bare dir lands
453
+ // here (D-58).
454
+ throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
455
+ }
456
+ return { root, packageGlobs }
457
+ }
458
+
459
+ // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
460
+ // glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
461
+ // path.
462
+ const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
463
+
464
+ /**
465
+ * True when a member glob reaches a `package.json` other than the root's,
466
+ * addressable or not: a nameless manifest, or two sharing a name, is
467
+ * matched and left out, not unmatched (M-46).
468
+ */
469
+ export async function reachesManifest(workspace: PackageWorkspace): Promise<boolean> {
470
+ const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
471
+ const root = path.resolve(workspace.root)
472
+ for (const pattern of positive) {
473
+ for (const dir of await memberDirs(workspace.root, pattern)) {
474
+ if (path.resolve(dir) === root) continue
475
+ if (negative.length > 0 && excludedBy(relPosix(workspace.root, dir), negative)) continue
476
+ try {
477
+ if ((await stat(path.join(dir, 'package.json'))).isFile()) return true
478
+ } catch (err) {
479
+ absent(err, undefined)
480
+ }
481
+ }
482
+ }
483
+ return false
484
+ }
485
+
486
+ /**
487
+ * The directories a workspace glob names. For the `<dir>/*` shape this is
488
+ * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
489
+ * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
490
+ * symlinked member is followed when it points at a directory, and so is
491
+ * it by the scan any other shape takes, unless the glob holds `**` (item
492
+ * 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
493
+ */
494
+ async function memberDirs(root: string, pattern: string): Promise<string[]> {
495
+ // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
496
+ const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
497
+ if (SIMPLE_STAR_RE.test(normalized)) {
498
+ const base = path.resolve(root, normalized.slice(0, -2))
499
+ let entries: import('node:fs').Dirent[]
500
+ try {
501
+ entries = await readdir(base, { withFileTypes: true })
502
+ } catch (err) {
503
+ return absent(err, [])
504
+ }
505
+ const dirs: string[] = []
506
+ for (const e of entries) {
507
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
508
+ if (e.isDirectory()) dirs.push(path.join(base, e.name))
509
+ else if (e.isSymbolicLink()) {
510
+ try {
511
+ if ((await stat(path.join(base, e.name))).isDirectory())
512
+ dirs.push(path.join(base, e.name))
513
+ } catch (err) {
514
+ absent(err, undefined) // dangling link: not a member
515
+ }
516
+ }
517
+ }
518
+ return dirs
519
+ }
520
+ const dirs: string[] = []
521
+ // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
522
+ // slash, though its `match` reads one: `packages/{a,nested/b}` listed
523
+ // neither package, and both ran under no verb while the root still
524
+ // claimed them (D-2). Such a pattern is scanned as its expansions.
525
+ for (const expanded of slashBraceExpansions(normalized)) {
526
+ const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
527
+ const glob = new Bun.Glob(globPattern)
528
+ // A linked member is a member, as the readdir path above has it:
529
+ // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
530
+ // `pack*/*` did not (item 987). Followed only where the depth is bounded:
531
+ // under `**` the scan would walk every pnpm `node_modules` link.
532
+ const followSymlinks = !expanded.includes('**')
533
+ try {
534
+ for await (const rel of glob.scan({
535
+ cwd: root,
536
+ onlyFiles: true,
537
+ dot: false,
538
+ followSymlinks,
539
+ })) {
540
+ // Skip nested node_modules — workspace package globs shouldn't
541
+ // ever reach into them, but a pathological pattern like `**`
542
+ // would. Avoid splitting the path on the hot loop.
543
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
544
+ if (rel.startsWith(`node_modules${path.sep}`)) continue
545
+ dirs.push(path.dirname(path.resolve(root, rel)))
546
+ }
547
+ } catch (err) {
548
+ // The scan stops at a directory it may not open and cannot skip it,
549
+ // so the readdir path's skip is not on offer here (D-132).
550
+ if (!isPermissionError(err)) throw err
551
+ const where = relPosix(root, path.resolve(root, err.path ?? ''))
552
+ throw new UserError(
553
+ `${where}: not readable by this user (${err.code}), and the workspace glob "${pattern}" walks into it`,
554
+ )
555
+ }
556
+ }
557
+ return dirs
558
+ }
559
+
560
+ /**
561
+ * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
562
+ *
563
+ * Two shapes, one per platform, each the measured winner there across
564
+ * 1,000 projects with the manifest read in flight alongside:
565
+ * - macOS stats the candidates in order: a readdir each cost 9.6 ms
566
+ * against 6.3 ms of stats even with the config at the LAST name
567
+ * (2026-09-03), and a `.ts` config — the first name — pays one stat.
568
+ * - Linux lists the directory once: a readdir each is 12 ms against
569
+ * 43 ms of stats at the last name and ties the one-stat case
570
+ * (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
571
+ * thread-pool round trip apiece).
572
+ * Both answer identically: a directory or dangling link under a config
573
+ * name is skipped for the next name, a symlink to a file counts.
574
+ */
575
+ async function findConfigFile(dir: string): Promise<string | null> {
576
+ if (process.platform === 'linux') {
577
+ let entries: Dirent[]
578
+ try {
579
+ entries = await readdir(dir, { withFileTypes: true })
580
+ } catch (err) {
581
+ return absent(err, null)
582
+ }
583
+ // By precedence slot, not a Map per directory, and no `path.join`: at
584
+ // 5,000 projects that cut `listPackages` from 53.5 to 47.7 ms (min of
585
+ // 15, six interleaved rounds, A/A 54.7).
586
+ const found: Dirent[] = []
587
+ for (const e of entries) {
588
+ const rank = CONFIG_RANK.get(e.name)
589
+ if (rank !== undefined) found[rank] = e
590
+ }
591
+ for (const entry of found) {
592
+ if (entry === undefined) continue
593
+ const candidate = dir + path.sep + entry.name
594
+ if (entry.isFile()) return candidate
595
+ if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
596
+ }
597
+ return null
598
+ }
599
+ for (const name of PROJECT_CONFIG_FILENAMES) {
600
+ const candidate = path.join(dir, name)
601
+ if (await isFile(candidate)) return candidate
602
+ }
603
+ return null
604
+ }
605
+
606
+ const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
607
+
608
+ async function isFile(p: string): Promise<boolean> {
609
+ try {
610
+ return (await stat(p)).isFile()
611
+ } catch (err) {
612
+ return absent(err, false)
613
+ }
614
+ }
615
+
616
+ /**
617
+ * A read that found nothing is absent; one the process could not make is
618
+ * not. Out of descriptors (`EMFILE`), every member read failed, and the
619
+ * catches here read the workspace as empty: "No package matched the
620
+ * workspace's package globs" (D-60).
621
+ */
622
+ function absent<T>(err: unknown, value: T): T {
623
+ if (isOutOfFds(err)) throw err
624
+ return value
625
+ }
626
+
627
+ /**
628
+ * A member's manifest text, or null when there is none. A manifest this
629
+ * user may not read is refused, not skipped: its project dropped out of
630
+ * `--all` and the run went green without it (D-132). A directory it may
631
+ * not search hides whether a manifest is there at all, so that one is
632
+ * named and skipped (a service's data directory under `packages/*`).
633
+ */
634
+ async function readManifest(root: string, dir: string, file: string): Promise<string | null> {
635
+ try {
636
+ return await Bun.file(file).text()
637
+ } catch (err) {
638
+ if (!isPermissionError(err)) return absent(err, null)
639
+ const searchable = await access(dir, constants.X_OK).then(
640
+ () => true,
641
+ () => false,
642
+ )
643
+ if (searchable) unreadable(err, file)
644
+ process.stderr.write(
645
+ `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it\n`,
646
+ )
647
+ return null
648
+ }
649
+ }
650
+
651
+ /** `dir` as a discovery line names it: the root's own path is '' (D-127, D-154). */
652
+ function shownDir(root: string, dir: string): string {
653
+ return relPosix(root, dir) || 'the workspace root'
654
+ }
655
+
656
+ /** The members of the package-manager workspace at `root`. */
657
+ export async function listPackages(root: string): Promise<PackageMeta[]> {
658
+ return discoverPackages(await loadPackageWorkspace(root))
659
+ }
660
+
661
+ /**
662
+ * The members of `workspace`, and `nameless` collects each member
663
+ * directory whose manifest has no `name`: the scripts mapper names them, since
664
+ * their scripts map to nothing.
665
+ */
666
+ export async function discoverPackages(
667
+ workspace: PackageWorkspace,
668
+ nameless?: string[],
669
+ ): Promise<PackageMeta[]> {
670
+ // Run all package globs concurrently. Disk-bound walks parallelize
671
+ // well; serializing them just stretches the discovery phase by N×.
672
+ const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
673
+ // A root the globs do not list is a project when it holds a vx config
674
+ // (D-39): the package manager's member list stays the manager's, and
675
+ // the root's globs stop at every member as any parent project's do.
676
+ const [perPattern, rootConfig] = await Promise.all([
677
+ Promise.all(positive.map((pattern) => memberDirs(workspace.root, pattern))),
678
+ findConfigFile(workspace.root),
679
+ ])
680
+ const matches = new Set<string>()
681
+ for (const arr of perPattern) {
682
+ for (const m of arr) {
683
+ if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
684
+ matches.add(m)
685
+ }
686
+ }
687
+ if (rootConfig !== null) matches.add(workspace.root)
688
+
689
+ // Per-project discovery: the manifest read answers "is there a
690
+ // package.json" (an ENOENT is the "not a member" answer, at the cost of
691
+ // one failed open) and `findConfigFile` answers "which config file", both
692
+ // in flight together. All async and all in flight at once — the thread
693
+ // pool runs them in parallel, and a sync read here measured 2× slower on
694
+ // a 1000-member workspace.
695
+ const loaded = await Promise.all(
696
+ [...matches].map(async (dir) => {
697
+ const pkgJsonPath = dir + path.sep + 'package.json'
698
+ const [configPath, text] = await Promise.all([
699
+ findConfigFile(dir),
700
+ readManifest(workspace.root, dir, pkgJsonPath),
701
+ ])
702
+ if (text === null) return null
703
+ const pkg = parsePackageJson(text, pkgJsonPath)
704
+ return { dir, pkg, configPath }
705
+ }),
706
+ )
707
+
708
+ const byName = new Map<string, NonNullable<(typeof loaded)[number]>[]>()
709
+ for (const entry of loaded) {
710
+ if (entry === null) continue
711
+ const { dir, pkg } = entry
712
+ // npm and pnpm take `../ext/*`, but `--affected` asks git from the root
713
+ // and sees nothing outside it: an edit there moved the task's key and
714
+ // selected nothing, green.
715
+ const rel = relPosix(workspace.root, dir)
716
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
717
+ throw new UserError(
718
+ `workspace member ${rel} (${dir}) is outside the workspace root ${workspace.root}: ` +
719
+ 'vx keeps every project under the root. Move the workspace root up to a directory that holds every member.',
720
+ )
721
+ }
722
+ if (!pkg.name) {
723
+ nameless?.push(dir)
724
+ continue
725
+ }
726
+ const group = byName.get(pkg.name)
727
+ if (group === undefined) byName.set(pkg.name, [entry])
728
+ else group.push(entry)
729
+ }
730
+ const projects: PackageMeta[] = []
731
+ const shared: string[] = []
732
+ let rootReal: string | undefined
733
+ for (const [name, found] of byName) {
734
+ // One package reached by two paths (a member and a link to it) is one
735
+ // project, not a name two packages share: `apps/docs -> ../packages/docs`
736
+ // was refused as a duplicate and told to rename one (D-135). Resolved
737
+ // only here, so a workspace without a shared name pays nothing.
738
+ let group = found
739
+ if (group.length > 1) {
740
+ rootReal ??= await realpath(workspace.root)
741
+ group = await oneEntryPerPackage(group, workspace.root, rootReal)
742
+ }
743
+ if (group.length === 1) {
744
+ const { dir, pkg, configPath } = group[0]!
745
+ projects.push({ name, dir, packageJson: pkg, configPath })
746
+ continue
747
+ }
748
+ // The root's own manifest is '' relative to itself, and the refusal read
749
+ // "in workspace: and packages/a" (D-127).
750
+ const dirs = group
751
+ .map((e) => relPosix(workspace.root, e.dir))
752
+ .sort()
753
+ .map((d) => (d === '' ? 'the workspace root' : d))
754
+ // pnpm accepts two manifests of one name (vite's playground, sveltejs/kit's
755
+ // test apps); vx cannot, since a project is addressed by it. Like a
756
+ // nameless one, a pair that declares no vx tasks is left out, so the
757
+ // rest of the workspace runs (vite was refused whole, 2026-10-01); one
758
+ // with a vx config was meant to run, and is refused with the way on.
759
+ if (group.some((e) => e.configPath !== null)) {
760
+ throw new UserError(
761
+ `Duplicate package name "${name}" in workspace: ${dirs.join(' and ')}; ` +
762
+ 'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
763
+ )
764
+ }
765
+ shared.push(`${name} (${dirs.join(', ')})`)
766
+ }
767
+ if (shared.length > 0) {
768
+ shared.sort()
769
+ const shown =
770
+ shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
771
+ process.stderr.write(
772
+ `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
773
+ )
774
+ }
775
+
776
+ // Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
777
+ // 300 ms warm run at 1000 projects, and a stable deterministic order is
778
+ // all any consumer needs.
779
+ return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
780
+ }
781
+
782
+ /**
783
+ * The entries of a name group, one per real directory. Of several paths to
784
+ * one package, the one that reaches it through no link inside the
785
+ * workspace is kept, else the first in path order.
786
+ */
787
+ async function oneEntryPerPackage<T extends { dir: string }>(
788
+ group: T[],
789
+ root: string,
790
+ rootReal: string,
791
+ ): Promise<T[]> {
792
+ const byReal = new Map<string, T[]>()
793
+ for (const entry of group) {
794
+ const real = realpathOf(entry.dir)
795
+ const same = byReal.get(real)
796
+ if (same === undefined) byReal.set(real, [entry])
797
+ else same.push(entry)
798
+ }
799
+ if (byReal.size === group.length) return group
800
+ const kept: T[] = []
801
+ for (const [real, paths] of byReal) {
802
+ const direct = paths.find((e) => relPosix(root, e.dir) === relPosix(rootReal, real))
803
+ kept.push(direct ?? paths.sort((a, b) => (a.dir < b.dir ? -1 : 1))[0]!)
804
+ }
805
+ return kept
806
+ }