@vzn/vx-migrate 0.0.636 → 0.0.638
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +298 -44
- package/package.json +3 -3
- package/src/adopt.ts +25 -8
- package/src/adoption-plugin.ts +2 -2
- package/src/manifest-graph.ts +65 -0
- package/src/manifests.ts +52 -0
- package/src/mapping-cache.ts +5 -9
- package/src/migrate-nx.ts +63 -22
- package/src/migrate-scripts.ts +1261 -0
- package/src/migrate-turbo.ts +6 -7
- package/src/migrate-vite-task.ts +5 -4
- package/src/migrate.ts +474 -173
- package/src/nx/export-graph.ts +15 -4
- package/src/nx/index.ts +82 -23
- package/src/nx/nx-boundaries.ts +191 -0
- package/src/nx/nx-deps.ts +3 -3
- package/src/nx/nx-map.ts +37 -41
- package/src/nx/nx-upstream.ts +3 -2
- package/src/nx-cache/index.ts +5 -11
- package/src/package-fs.ts +152 -0
- package/src/packages.ts +806 -0
- package/src/paths.ts +2 -2
- package/src/plugin-gaps.ts +20 -5
- package/src/remote-cache.ts +118 -0
- package/src/shared-outputs.ts +63 -19
- package/src/tracked-outputs.ts +54 -35
- package/src/turbo/index.ts +32 -20
- package/src/turbo/turbo-map.ts +96 -56
- package/src/turbo-cache/index.ts +14 -20
- package/src/vite-task/vite-task-map.ts +13 -12
- package/src/workspace-notes.ts +12 -6
- package/src/workspace-plugins.ts +41 -11
package/src/packages.ts
ADDED
|
@@ -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
|
+
}
|