@vzn/vx 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.
- package/README.md +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -1,59 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// What a project is (owner, 2026-10-14): a directory holding a
|
|
2
|
+
// `vx.config.*`. `vx lock` finds them (`findProjectConfigs`) and records
|
|
3
|
+
// each by directory; a run takes them from the lock (`lockedProjects`) and
|
|
4
|
+
// never walks. A project is named by `name` in its config, else by a
|
|
5
|
+
// `discover` plugin, else by its root-relative path, `//` for the root.
|
|
6
|
+
// Design: docs/design/language-agnostic-core-2026-10.md.
|
|
7
|
+
import type { Dirent } from 'node:fs'
|
|
8
|
+
import { lstat, readdir, stat } from 'node:fs/promises'
|
|
3
9
|
import { homedir } from 'node:os'
|
|
4
10
|
import path from 'node:path'
|
|
5
11
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
6
12
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
executablePath,
|
|
14
|
+
gitEnv,
|
|
15
|
+
gitSpawnRefusal,
|
|
16
|
+
isExecutableMissing,
|
|
9
17
|
isOutOfFds,
|
|
10
|
-
isPermissionError,
|
|
11
18
|
relPosix,
|
|
12
|
-
slashBraceExpansions,
|
|
13
19
|
UserError,
|
|
14
|
-
normalizeBunGlob,
|
|
15
|
-
realpathOf,
|
|
16
20
|
} from '../util/index.js'
|
|
17
|
-
import {
|
|
21
|
+
import { gitTop, workspaceFile, workspaceRootOf } from '../handoff.js'
|
|
18
22
|
import { repoIdOf } from './repo-id.js'
|
|
19
23
|
|
|
20
|
-
export interface PackageJson {
|
|
21
|
-
name: string
|
|
22
|
-
version?: string
|
|
23
|
-
dependencies?: Record<string, string>
|
|
24
|
-
devDependencies?: Record<string, string>
|
|
25
|
-
peerDependencies?: Record<string, string>
|
|
26
|
-
optionalDependencies?: Record<string, string>
|
|
27
|
-
/** npm / yarn / bun workspaces. May be a glob array or { packages: [...] }. */
|
|
28
|
-
workspaces?: string[] | { packages?: string[] }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface Workspace {
|
|
32
|
-
root: string
|
|
33
|
-
/** Glob patterns relative to root that match project directories. */
|
|
34
|
-
packageGlobs: string[]
|
|
35
|
-
/** The package manager's catalogs, when the root declares any. */
|
|
36
|
-
catalogs?: Catalogs
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Catalog name → dependency → the spec a `catalog:<name>` entry stands
|
|
41
|
-
* for; `catalog:` alone is `default`. pnpm's from `pnpm-workspace.yaml`
|
|
42
|
-
* (`catalog`, `catalogs`), bun's from the root `package.json` (the same
|
|
43
|
-
* keys, top level or under `workspaces`).
|
|
44
|
-
*/
|
|
45
|
-
export type Catalogs = ReadonlyMap<string, Readonly<Record<string, unknown>>>
|
|
46
|
-
|
|
47
24
|
export interface ProjectMeta {
|
|
48
|
-
/**
|
|
25
|
+
/** `name` in its config, else a `discover` plugin's, else its root-relative path (`//` for the root). */
|
|
49
26
|
name: string
|
|
50
27
|
/** Absolute path to the project directory. */
|
|
51
28
|
dir: string
|
|
52
|
-
|
|
53
|
-
/** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
|
|
29
|
+
/** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs}; null for a project a plugin added. */
|
|
54
30
|
configPath: string | null
|
|
55
|
-
/**
|
|
56
|
-
|
|
31
|
+
/** Projects a `discover` plugin says this one builds after (order and reach). */
|
|
32
|
+
deps?: readonly string[]
|
|
33
|
+
/** Projects a `discover` plugin says a change reaches this one through, with no order. */
|
|
34
|
+
reach?: readonly string[]
|
|
57
35
|
}
|
|
58
36
|
|
|
59
37
|
/** A discovered project joined with its loaded vx config. */
|
|
@@ -74,399 +52,73 @@ export const PROJECT_CONFIG_FILENAMES = [
|
|
|
74
52
|
'vx.config.cjs',
|
|
75
53
|
]
|
|
76
54
|
|
|
77
|
-
const decoder = new TextDecoder()
|
|
78
|
-
|
|
79
55
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
56
|
+
* The workspace root for `start`: the directory holding the first
|
|
57
|
+
* `.vx/workspace.*` from `start` up to the git top-level, or up to `/`
|
|
58
|
+
* outside git ({@link workspaceFile}, shared with the hand-off). Nothing
|
|
59
|
+
* else decides it (owner, 2026-10-13: "Workspace file is the ultimate"):
|
|
60
|
+
* not a `package.json`, not `pnpm-workspace.yaml`, not a lockfile. Throws
|
|
61
|
+
* `VX_E_NO_WORKSPACE` when there is none.
|
|
82
62
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* and "the root that lists me as a project" cannot diverge. An outer root
|
|
91
|
-
* that lists both the claimer and the claimed member outranks the claimer:
|
|
92
|
-
* from the claimer's own directory the walk reaches the outer root too.
|
|
93
|
-
*
|
|
94
|
-
* When no candidate claims `start` — a standalone package, or a subdirectory
|
|
95
|
-
* of a single-project repo — the nearest candidate wins (the root itself IS
|
|
96
|
-
* the project). Throws a `UserError` when there is no candidate before `/`.
|
|
97
|
-
* A load that goes on to `loadWorkspace` passes its `reads`, so the
|
|
98
|
-
* root's manifest is read once for both.
|
|
63
|
+
* Outside git, a file another user owns is refused (root aside): a
|
|
64
|
+
* `.vx/workspace.ts` planted in a shared directory above you (`/tmp`) is
|
|
65
|
+
* code that would run as you. The owner is the file's own, a link's
|
|
66
|
+
* included, and its `.vx` directory's. Inside git the walk never leaves your
|
|
67
|
+
* checkout. The directory you run in is yours to judge, and costs no
|
|
68
|
+
* stat. Run as root, vx trusts every owner, as a container over a mounted
|
|
69
|
+
* checkout needs. `uid` is for tests.
|
|
99
70
|
*/
|
|
100
71
|
export async function findWorkspaceRoot(
|
|
101
72
|
start: string,
|
|
102
|
-
|
|
73
|
+
uid: number = process.getuid?.() ?? 0,
|
|
103
74
|
): Promise<string> {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let claimed: string | null = null
|
|
112
|
-
let members: string[] = []
|
|
113
|
-
while (true) {
|
|
114
|
-
let globs: string[] | null
|
|
115
|
-
try {
|
|
116
|
-
globs = await readPackageGlobs(dir, reads)
|
|
117
|
-
} catch (err) {
|
|
118
|
-
if (isOutOfFds(err)) throw err
|
|
119
|
-
// An unparseable manifest is still a root SIGNAL (the pre-existing
|
|
120
|
-
// behaviour probed only for existence); it just can't claim members.
|
|
121
|
-
// `loadWorkspace` surfaces the parse error if this dir is chosen.
|
|
122
|
-
globs = []
|
|
123
|
-
}
|
|
124
|
-
if (globs !== null) {
|
|
125
|
-
nearest ??= dir
|
|
126
|
-
if (claimed !== null) {
|
|
127
|
-
// An outer root that lists both the claimer and the member owns
|
|
128
|
-
// them: `packages/**` holding `packages/inner` (with `workspaces:
|
|
129
|
-
// ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
|
|
130
|
-
// `inner` to the outer root, two roots and two keys for one tree.
|
|
131
|
-
if (
|
|
132
|
-
(await claimsMember(dir, [claimed], globs)) &&
|
|
133
|
-
(await claimsMember(dir, members, globs))
|
|
134
|
-
)
|
|
135
|
-
claimed = dir
|
|
136
|
-
} else if (await claimsMember(dir, inner === null ? below : [inner], globs)) {
|
|
137
|
-
claimed = dir
|
|
138
|
-
members = inner === null ? [...below] : [inner]
|
|
139
|
-
}
|
|
140
|
-
// pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
|
|
141
|
-
// not. Walking past it, `apps/inner` resolved to the outer workspace
|
|
142
|
-
// while `apps/inner/pkgs/x` resolved to the inner one: two roots and
|
|
143
|
-
// two caches for one tree (item 990). Already read: `reads` holds it.
|
|
144
|
-
if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
|
|
145
|
-
return claimed ?? dir
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const parent = path.dirname(dir)
|
|
149
|
-
if (parent === dir) break
|
|
150
|
-
// Only a directory holding a manifest can be a member: a glob that
|
|
151
|
-
// matched `packages/tools`, which has none, claimed the standalone
|
|
152
|
-
// package below it, and `packages/tools/standalone` ran as a stranger
|
|
153
|
-
// in a workspace that does not list it (item 989).
|
|
154
|
-
if (globs !== null) below.push(dir)
|
|
155
|
-
// `apps/tool/workspace` with its own `workspaces`, under a root listing
|
|
156
|
-
// `apps/*`, resolved to the outer root through `apps/tool`, and its
|
|
157
|
-
// members ran in a workspace that does not list them (D-137). npm makes
|
|
158
|
-
// an outer root own a nested one only when it lists that directory.
|
|
159
|
-
if (inner === null && globs !== null && globs.length > 0) inner = dir
|
|
160
|
-
dir = parent
|
|
161
|
-
}
|
|
162
|
-
if (claimed !== null) return claimed
|
|
163
|
-
if (nearest !== null) return nearest
|
|
164
|
-
throw new UserError(
|
|
165
|
-
`Could not find a workspace root in any parent of ${start} ` +
|
|
166
|
-
`(looked for pnpm-workspace.yaml or package.json): run vx inside a project, ` +
|
|
167
|
-
`or create a package.json (\`bun init\` or \`npm init -y\`) and run \`vx init\``,
|
|
168
|
-
'VX_E_NO_WORKSPACE',
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** True when one of `below` (dirs under `root`, toward `start`) is a member. */
|
|
173
|
-
async function claimsMember(
|
|
174
|
-
root: string,
|
|
175
|
-
below: readonly string[],
|
|
176
|
-
globs: readonly string[],
|
|
177
|
-
): Promise<boolean> {
|
|
178
|
-
if (below.length === 0 || globs.length === 0) return false
|
|
179
|
-
const { positive, negative } = splitPackageGlobs(globs)
|
|
180
|
-
const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
|
|
181
|
-
for (const pattern of positive) {
|
|
182
|
-
const normalized = pattern.replace(/\/+$/, '')
|
|
183
|
-
// `.` means the root itself is the project — never a directory below it.
|
|
184
|
-
if (normalized === '' || normalized === '.') continue
|
|
185
|
-
// Matched as discovery scans, on the manifest: `packages/**` lists
|
|
186
|
-
// `packages/package.json`, while against the directory `**` needs a
|
|
187
|
-
// segment below `packages`, and a run from there was its own root.
|
|
188
|
-
const glob = new Bun.Glob(`${normalized}/package.json`)
|
|
189
|
-
for (const rel of rels) {
|
|
190
|
-
if (!glob.match(`${rel}/package.json`)) continue
|
|
191
|
-
// `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
|
|
192
|
-
// which discovery skips, and a run from inside it found a workspace
|
|
193
|
-
// that does not list it. Such a path asks discovery's own walk.
|
|
194
|
-
if (!SKIPPED_SEGMENT.test(rel)) return true
|
|
195
|
-
const dirs = await memberDirs(root, pattern)
|
|
196
|
-
if (dirs.includes(path.join(root, rel))) return true
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return false
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const SKIPPED_SEGMENT = /(^|\/)(\.|node_modules(\/|$))/
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
|
|
206
|
-
* list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
|
|
207
|
-
* so `!packages/fixtures/package.json` matched every manifest in the tree
|
|
208
|
-
* and every directory holding one became a project — the excluded package
|
|
209
|
-
* ran under `--all`, and a fixture repeating a name killed the run with
|
|
210
|
-
* "Duplicate package name". Negations subtract from what the positive
|
|
211
|
-
* globs found; a literal one excludes its tree.
|
|
212
|
-
*/
|
|
213
|
-
function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
|
|
214
|
-
const positive: string[] = []
|
|
215
|
-
const negative: string[] = []
|
|
216
|
-
// `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
|
|
217
|
-
// (2026-09-10): the same spellings the input globs normalize.
|
|
218
|
-
for (const raw of globs) {
|
|
219
|
-
// A member glob names directories, so a trailing slash says nothing:
|
|
220
|
-
// npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
|
|
221
|
-
// gives it a task glob's meaning, `/**`, and `packages/*/` found every
|
|
222
|
-
// example and fixture package at any depth below the members (item 985).
|
|
223
|
-
const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
|
|
224
|
-
if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
|
|
225
|
-
else positive.push(g)
|
|
226
|
-
}
|
|
227
|
-
return { positive, negative }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function excludedBy(rel: string, negative: readonly string[]): boolean {
|
|
231
|
-
for (const neg of negative) {
|
|
232
|
-
if (neg.length === 0) continue
|
|
233
|
-
if (rel === neg || rel.startsWith(`${neg}/`)) return true
|
|
234
|
-
if (!BUN_GLOB_WILDCARDS.test(neg)) continue
|
|
235
|
-
if (new Bun.Glob(neg).match(rel)) return true
|
|
236
|
-
// pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
|
|
237
|
-
// `!**/test/**` excludes `packages/test` itself; matched against the
|
|
238
|
-
// directory, `**/test/**` needs something below `test` and the package
|
|
239
|
-
// stayed a member (item 986).
|
|
240
|
-
if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
|
|
241
|
-
}
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Package globs declared by `dir`, or `null` when `dir` is not a root
|
|
247
|
-
* candidate. A bare `package.json` (no `workspaces`) is single-project mode:
|
|
248
|
-
* the root itself is the only project, hence `['.']`.
|
|
249
|
-
*/
|
|
250
|
-
async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
|
|
251
|
-
return (await readRootManifests(dir, reads)).globs
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** `readPackageGlobs`, and the catalogs the same manifests declare. */
|
|
255
|
-
async function readRootManifests(
|
|
256
|
-
dir: string,
|
|
257
|
-
reads?: LoadReads,
|
|
258
|
-
): Promise<{ globs: string[] | null; catalogs: Catalogs | undefined }> {
|
|
259
|
-
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
260
|
-
const yaml = await readOnce(reads, yamlPath)
|
|
261
|
-
let yamlWithoutPackages = false
|
|
262
|
-
// pnpm reads its catalogs from this file alone, bun from package.json.
|
|
263
|
-
let catalogs: Catalogs | undefined
|
|
264
|
-
if (yaml !== null) {
|
|
265
|
-
const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
|
|
266
|
-
if (parsed !== null && parsed !== undefined) {
|
|
267
|
-
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
268
|
-
throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
|
|
269
|
-
}
|
|
270
|
-
catalogs = catalogsOf([parsed])
|
|
271
|
-
const packages = (parsed as { packages?: unknown }).packages
|
|
272
|
-
// A list commented out leaves `packages:` null, which pnpm reads as
|
|
273
|
-
// absent; vx refused it as "must be an array" (D-149).
|
|
274
|
-
if (packages !== undefined && packages !== null) {
|
|
275
|
-
return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// No `packages:` (or an empty one): pnpm 10 keeps its settings and catalogs in this file
|
|
279
|
-
// for a single-package repo too. The root's package.json decides, as it
|
|
280
|
-
// would without the file; read as an empty list, the root found zero
|
|
281
|
-
// projects and every verb ran nothing and exited 0 (item 984).
|
|
282
|
-
yamlWithoutPackages = true
|
|
283
|
-
}
|
|
284
|
-
const pkgPath = path.join(dir, 'package.json')
|
|
285
|
-
const pkgBytes = await readOnce(reads, pkgPath)
|
|
286
|
-
if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
|
|
287
|
-
const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
|
|
288
|
-
const ws = pkg.workspaces as unknown
|
|
289
|
-
if (yaml === null) {
|
|
290
|
-
catalogs = catalogsOf(
|
|
291
|
-
ws !== null && typeof ws === 'object' && !Array.isArray(ws) ? [pkg, ws] : [pkg],
|
|
75
|
+
start = path.resolve(start)
|
|
76
|
+
const file = await workspaceFile(start)
|
|
77
|
+
if (file === null) {
|
|
78
|
+
throw new UserError(
|
|
79
|
+
`no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'} — ` +
|
|
80
|
+
'run `vx init` to create one',
|
|
81
|
+
'VX_E_NO_WORKSPACE',
|
|
292
82
|
)
|
|
293
83
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* The `catalog` and `catalogs` keys of the manifests (bun reads them at
|
|
312
|
-
* the top level and under `workspaces`), or undefined when none declares
|
|
313
|
-
* one. An entry that is not a mapping is no catalog: a `catalog:` spec
|
|
314
|
-
* through it keeps its edge, as one through an unknown catalog does.
|
|
315
|
-
*/
|
|
316
|
-
function catalogsOf(manifests: readonly object[]): Catalogs | undefined {
|
|
317
|
-
const isMap = (v: unknown): v is Record<string, unknown> =>
|
|
318
|
-
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
319
|
-
let out: Map<string, Readonly<Record<string, unknown>>> | undefined
|
|
320
|
-
for (const m of manifests) {
|
|
321
|
-
const { catalog, catalogs } = m as { catalog?: unknown; catalogs?: unknown }
|
|
322
|
-
if (isMap(catalogs)) {
|
|
323
|
-
for (const [name, c] of Object.entries(catalogs))
|
|
324
|
-
if (isMap(c)) (out ??= new Map()).set(name, c)
|
|
325
|
-
}
|
|
326
|
-
// `catalog` is the default catalog to pnpm and bun.
|
|
327
|
-
if (isMap(catalog)) (out ??= new Map()).set('default', catalog)
|
|
328
|
-
}
|
|
329
|
-
return out
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Package manifests the globs never reach, in single-project mode only: a
|
|
334
|
-
* root `package.json` with no `workspaces` field makes the root the one
|
|
335
|
-
* project, and a `packages/app/package.json` full of scripts beside it is
|
|
336
|
-
* then invisible — `vx init` said "no package.json scripts" and `vx run`
|
|
337
|
-
* said "run vx init" (2026-09-16). One shallow scan, two levels, on the
|
|
338
|
-
* failure path only; `node_modules` and dot directories are skipped.
|
|
339
|
-
*/
|
|
340
|
-
export async function unreachedPackages(workspace: Workspace): Promise<string[]> {
|
|
341
|
-
if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
|
|
342
|
-
const found: string[] = []
|
|
343
|
-
// Two scans: Bun.Glob does not expand a brace whose alternatives hold a
|
|
344
|
-
// slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
|
|
345
|
-
for (const pattern of ['*/package.json', '*/*/package.json']) {
|
|
346
|
-
for await (const rel of new Bun.Glob(pattern).scan({
|
|
347
|
-
cwd: workspace.root,
|
|
348
|
-
onlyFiles: true,
|
|
349
|
-
dot: false,
|
|
350
|
-
})) {
|
|
351
|
-
const posix = rel.split(path.sep).join('/')
|
|
352
|
-
if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
|
|
353
|
-
found.push(posix.slice(0, -'/package.json'.length))
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return found.sort()
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* The line for `unreachedPackages`' finding: the cause, the packages, the
|
|
361
|
-
* glob to add, and where. A `pnpm-workspace.yaml` with no `packages:` (pnpm
|
|
362
|
-
* keeps its settings there) is where pnpm reads the globs: the hint said
|
|
363
|
-
* there was no such file and named package.json, beside ngrx's (2026-10-07).
|
|
364
|
-
*/
|
|
365
|
-
export function unreachedHint(unreached: readonly string[], root: string): string {
|
|
366
|
-
const n = unreached.length
|
|
367
|
-
const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
|
|
368
|
-
const globs = [
|
|
369
|
-
...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
|
|
370
|
-
]
|
|
371
|
-
.map((g) => `"${g}"`)
|
|
372
|
-
.join(', ')
|
|
373
|
-
const rest = `so the root is the only project and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}.`
|
|
374
|
-
return existsSync(path.join(root, 'pnpm-workspace.yaml'))
|
|
375
|
-
? `pnpm-workspace.yaml lists no \`packages\`, ${rest} Add \`packages: [${globs}]\` to pnpm-workspace.yaml and re-run.`
|
|
376
|
-
: `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), ${rest} ` +
|
|
377
|
-
`Add \`"workspaces": [${globs}]\` to package.json and re-run.`
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Parse a workspace manifest, naming the FILE on failure. The raw parser
|
|
382
|
-
* errors (`Failed to parse JSON`) carry no path, so in a 1000-package
|
|
383
|
-
* monorepo they say nothing about which manifest is broken.
|
|
384
|
-
*/
|
|
385
|
-
function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
|
|
386
|
-
try {
|
|
387
|
-
return parse(text)
|
|
388
|
-
} catch (err) {
|
|
389
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
390
|
-
throw new UserError(`failed to parse ${file}: ${msg}`)
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* A `package.json` as discovery reads it: a JSON object whose `name`, when
|
|
396
|
-
* present, is a string with no surrounding whitespace (npm refuses one).
|
|
397
|
-
* Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
|
|
398
|
-
* `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
|
|
399
|
-
* projects (item 988).
|
|
400
|
-
*/
|
|
401
|
-
function parsePackageJson(text: string, file: string): PackageJson {
|
|
402
|
-
const pkg = parseManifest(text, file, JSON.parse)
|
|
403
|
-
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
|
|
404
|
-
throw new UserError(`${file}: must be a JSON object`)
|
|
405
|
-
}
|
|
406
|
-
const name = (pkg as { name?: unknown }).name
|
|
407
|
-
if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
|
|
408
|
-
throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
|
|
409
|
-
}
|
|
410
|
-
// A task is `<project>#<task>` split at the first `#`: `"a#b"` planned
|
|
411
|
-
// under `--all` but no run spec or dependsOn could reach it (D-130).
|
|
412
|
-
if (typeof name === 'string' && name.includes('#')) {
|
|
413
|
-
throw new UserError(`${file}: "name" cannot hold "#" — vx addresses a task as <name>#<task>`)
|
|
414
|
-
}
|
|
415
|
-
return pkg as PackageJson
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* A glob list is user input from a manifest: `packages: "packages/*"`
|
|
420
|
-
* (a bare string instead of a list) is an easy YAML slip, and without
|
|
421
|
-
* this it surfaces as `workspace.packageGlobs.map is not a function`.
|
|
422
|
-
*/
|
|
423
|
-
function assertGlobList(value: unknown, file: string, field: string): string[] {
|
|
424
|
-
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
|
|
425
|
-
throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
|
|
426
|
-
}
|
|
427
|
-
for (const pattern of value as string[]) {
|
|
428
|
-
if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
|
|
429
|
-
}
|
|
430
|
-
return value as string[]
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function extglobRefusal(pattern: string, file: string, field: string): UserError {
|
|
434
|
-
const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
|
|
435
|
-
// The common shape, a whole segment excluding plain names, has an exact
|
|
436
|
-
// rewrite in the `!` entries every package manager takes.
|
|
437
|
-
const segments = pattern.split('/')
|
|
438
|
-
const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
|
|
439
|
-
const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
|
|
440
|
-
const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
|
|
441
|
-
const rest = segments.filter((_s, i) => i !== at).join('/')
|
|
442
|
-
if (at === -1 || !plain || EXTGLOB.test(rest)) {
|
|
443
|
-
return new UserError(
|
|
444
|
-
`${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
|
|
84
|
+
const root = workspaceRootOf(file)
|
|
85
|
+
if (uid !== 0 && root !== start && (await gitTop(start)) === null) {
|
|
86
|
+
// The link itself and the `.vx` holding it, not what a link names: a
|
|
87
|
+
// link another user planted to a root-owned file passed as root's, and
|
|
88
|
+
// its owner could point it at their own code once it had.
|
|
89
|
+
const owners = await Promise.all(
|
|
90
|
+
[lstat(file), stat(path.dirname(file))].map((p) =>
|
|
91
|
+
p.then(
|
|
92
|
+
(st) => st.uid,
|
|
93
|
+
() => uid,
|
|
94
|
+
),
|
|
95
|
+
),
|
|
445
96
|
)
|
|
97
|
+
if (owners.some((owner) => owner !== uid && owner !== 0)) {
|
|
98
|
+
throw new UserError(
|
|
99
|
+
`${file} belongs to another user, so vx does not take its directory as your workspace: ` +
|
|
100
|
+
'its config would run as you. Run vx in a checkout you own, or run `vx init` in yours',
|
|
101
|
+
'VX_E_NO_WORKSPACE',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
446
104
|
}
|
|
447
|
-
|
|
448
|
-
const rewrite = [
|
|
449
|
-
[...before, '*', ...segments.slice(at + 1)].join('/'),
|
|
450
|
-
...names.map((n) => `!${[...before, n].join('/')}`),
|
|
451
|
-
]
|
|
452
|
-
return new UserError(
|
|
453
|
-
`${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
|
|
454
|
-
)
|
|
105
|
+
return root
|
|
455
106
|
}
|
|
456
107
|
|
|
457
108
|
/**
|
|
458
|
-
* The workspace's own cache directory (index, history, memos):
|
|
459
|
-
*
|
|
460
|
-
* to the workspace root.
|
|
109
|
+
* The workspace's own cache directory (index, history, memos):
|
|
110
|
+
* `VX_CACHE_DIR`, else the workspace's `cache.dir`, else `.vx/cache`,
|
|
111
|
+
* relative to the workspace root (`--cache-dir` is the caller's, above both).
|
|
461
112
|
*/
|
|
462
113
|
export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
|
|
463
114
|
const env = process.env['VX_CACHE_DIR']
|
|
464
|
-
// `
|
|
115
|
+
// `cache.dir: ' '` is refused at load (X-22); the variable made the same
|
|
465
116
|
// directory named in spaces at the root (D-160).
|
|
466
|
-
if (
|
|
117
|
+
if (env !== undefined && env !== '' && env.trim() === '') {
|
|
467
118
|
throw new UserError('VX_CACHE_DIR is only whitespace — name a directory, or unset it')
|
|
468
119
|
}
|
|
469
|
-
const
|
|
120
|
+
const fromEnv = env !== undefined && env !== ''
|
|
121
|
+
const rel = (fromEnv ? env : config?.cache?.dir) ?? path.join('.vx', 'cache')
|
|
470
122
|
const home = process.env['HOME'] || homedir()
|
|
471
123
|
// No shell expands `~` in a config string or a quoted variable, and
|
|
472
124
|
// `'~/.cache/vx'` made a directory named `~` in the workspace.
|
|
@@ -475,7 +127,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
|
|
|
475
127
|
// The cache writes a `*` .gitignore beside its index: a bare `~` made a
|
|
476
128
|
// directory named `~`, and expanded it would land in home itself (D-153).
|
|
477
129
|
if (path.resolve(dir) === path.resolve(home)) {
|
|
478
|
-
const source =
|
|
130
|
+
const source = fromEnv ? 'VX_CACHE_DIR' : 'cache.dir'
|
|
479
131
|
throw new UserError(
|
|
480
132
|
`${source} ${JSON.stringify(rel)} is the home directory itself, where the cache's \`*\` .gitignore and index would land — name a directory under it, like '~/.cache/vx'`,
|
|
481
133
|
)
|
|
@@ -487,7 +139,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
|
|
|
487
139
|
* Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
|
|
488
140
|
* Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
|
|
489
141
|
* the repository hits what another saved and no other repository shares
|
|
490
|
-
* it (`repoIdOf`). Null when the workspace names its cache (`
|
|
142
|
+
* it (`repoIdOf`). Null when the workspace names its cache (`cache.dir`,
|
|
491
143
|
* `VX_CACHE_DIR`): that directory then holds everything, as one
|
|
492
144
|
* workspace's alone. Null with no repository identity or no home.
|
|
493
145
|
*/
|
|
@@ -495,7 +147,7 @@ export async function resolveStoreRoot(
|
|
|
495
147
|
root: string,
|
|
496
148
|
config: WorkspaceConfig | null,
|
|
497
149
|
): Promise<string | null> {
|
|
498
|
-
if (config?.
|
|
150
|
+
if (config?.cache?.dir !== undefined || process.env['VX_CACHE_DIR']) return null
|
|
499
151
|
// HOME first: Bun's homedir() keeps the HOME the process started with (1.4.2).
|
|
500
152
|
const home = process.env['HOME'] || homedir()
|
|
501
153
|
if (!path.isAbsolute(home)) return null
|
|
@@ -508,483 +160,362 @@ export async function resolveStoreRoot(
|
|
|
508
160
|
const repoIds = new Map<string, Promise<string | null>>()
|
|
509
161
|
|
|
510
162
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* - `package.json` `workspaces` array (npm / yarn / bun)
|
|
515
|
-
* - `package.json` `workspaces.packages` array (yarn legacy)
|
|
516
|
-
*
|
|
517
|
-
* If a `package.json` exists with no `workspaces` field, the root
|
|
518
|
-
* itself is treated as a single-project workspace.
|
|
163
|
+
* Sets each project's `deps` / `reach` from the `discover` entries' edge
|
|
164
|
+
* directories, resolved against every project the stage left. A target
|
|
165
|
+
* outside the root or that is no project is refused naming the plugin.
|
|
519
166
|
*/
|
|
520
|
-
export
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
167
|
+
export function linkDiscoveredEdges(
|
|
168
|
+
root: string,
|
|
169
|
+
projects: readonly ProjectMeta[],
|
|
170
|
+
edges: ReadonlyArray<{
|
|
171
|
+
readonly by: string
|
|
172
|
+
readonly meta: ProjectMeta
|
|
173
|
+
readonly deps: readonly string[] | undefined
|
|
174
|
+
readonly reach: readonly string[]
|
|
175
|
+
}>,
|
|
176
|
+
): void {
|
|
177
|
+
if (edges.length === 0) return
|
|
178
|
+
const byDir = new Map(projects.map((p) => [p.dir, p.name]))
|
|
179
|
+
const deps = new Map<ProjectMeta, Set<string>>()
|
|
180
|
+
const reach = new Map<ProjectMeta, Set<string>>()
|
|
181
|
+
for (const edge of edges) {
|
|
182
|
+
// An entry carrying `deps`, even none, makes its project a leaf of an
|
|
183
|
+
// edge source rather than a project no plugin spoke for.
|
|
184
|
+
if (edge.deps !== undefined && !deps.has(edge.meta)) deps.set(edge.meta, new Set())
|
|
185
|
+
for (const [kind, into] of [
|
|
186
|
+
[edge.deps ?? [], deps],
|
|
187
|
+
[edge.reach, reach],
|
|
188
|
+
] as const) {
|
|
189
|
+
for (const target of kind) {
|
|
190
|
+
const dir = path.resolve(root, target)
|
|
191
|
+
const rel = relPosix(root, dir)
|
|
192
|
+
const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
|
|
193
|
+
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
194
|
+
throw new UserError(`${where}: outside the workspace root`)
|
|
195
|
+
}
|
|
196
|
+
const name = byDir.get(dir)
|
|
197
|
+
if (name === undefined) throw new UserError(`${where}: not a project`)
|
|
198
|
+
let set = into.get(edge.meta)
|
|
199
|
+
if (set === undefined) into.set(edge.meta, (set = new Set()))
|
|
200
|
+
set.add(name)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
527
203
|
}
|
|
528
|
-
|
|
204
|
+
for (const [meta, names] of deps) meta.deps = [...names].sort()
|
|
205
|
+
for (const [meta, names] of reach) meta.reach = [...names].sort()
|
|
529
206
|
}
|
|
530
207
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// path.
|
|
534
|
-
const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
|
|
208
|
+
/** The root project's name when nothing names it: Turbo's spelling, and the root alias in filters and `dependsOn`. */
|
|
209
|
+
export const ROOT_NAME = '//'
|
|
535
210
|
|
|
536
|
-
/**
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
* or removed without walking anything. A glob of another shape (`apps/**`)
|
|
540
|
-
* has no such directory and contributes none.
|
|
541
|
-
*/
|
|
542
|
-
export function memberBaseDirs(workspace: Workspace): string[] {
|
|
543
|
-
const bases = new Set<string>()
|
|
544
|
-
for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
|
|
545
|
-
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
546
|
-
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
547
|
-
bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return [...bases]
|
|
211
|
+
/** A project's name when nothing names it: its root-relative POSIX path, `//` for the root. */
|
|
212
|
+
export function pathName(root: string, dir: string): string {
|
|
213
|
+
return relPosix(root, dir) || ROOT_NAME
|
|
551
214
|
}
|
|
552
215
|
|
|
553
|
-
|
|
554
|
-
* True when a member glob reaches a `package.json` other than the root's,
|
|
555
|
-
* addressable or not: a nameless manifest, or two sharing a name, is
|
|
556
|
-
* matched and left out, not unmatched (M-46).
|
|
557
|
-
*/
|
|
558
|
-
export async function reachesManifest(workspace: Workspace): Promise<boolean> {
|
|
559
|
-
const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
|
|
560
|
-
const root = path.resolve(workspace.root)
|
|
561
|
-
for (const pattern of positive) {
|
|
562
|
-
for (const dir of await memberDirs(workspace.root, pattern)) {
|
|
563
|
-
if (path.resolve(dir) === root) continue
|
|
564
|
-
if (negative.length > 0 && excludedBy(relPosix(workspace.root, dir), negative)) continue
|
|
565
|
-
try {
|
|
566
|
-
if ((await stat(path.join(dir, 'package.json'))).isFile()) return true
|
|
567
|
-
} catch (err) {
|
|
568
|
-
absent(err, undefined)
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
return false
|
|
573
|
-
}
|
|
216
|
+
const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
|
|
574
217
|
|
|
575
|
-
/**
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
* it by the scan any other shape takes, unless the glob holds `**` (item
|
|
581
|
-
* 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
|
|
582
|
-
*/
|
|
583
|
-
async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
584
|
-
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
585
|
-
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
586
|
-
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
587
|
-
const base = path.resolve(root, normalized.slice(0, -2))
|
|
588
|
-
let entries: import('node:fs').Dirent[]
|
|
589
|
-
try {
|
|
590
|
-
entries = await readdir(base, { withFileTypes: true })
|
|
591
|
-
} catch (err) {
|
|
592
|
-
return absent(err, [])
|
|
593
|
-
}
|
|
594
|
-
const dirs: string[] = []
|
|
595
|
-
for (const e of entries) {
|
|
596
|
-
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
597
|
-
if (e.isDirectory()) dirs.push(path.join(base, e.name))
|
|
598
|
-
else if (e.isSymbolicLink()) {
|
|
599
|
-
try {
|
|
600
|
-
if ((await stat(path.join(base, e.name))).isDirectory())
|
|
601
|
-
dirs.push(path.join(base, e.name))
|
|
602
|
-
} catch (err) {
|
|
603
|
-
absent(err, undefined) // dangling link: not a member
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return dirs
|
|
608
|
-
}
|
|
609
|
-
const dirs: string[] = []
|
|
610
|
-
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
611
|
-
// slash, though its `match` reads one: `packages/{a,nested/b}` listed
|
|
612
|
-
// neither package, and both ran under no verb while the root still
|
|
613
|
-
// claimed them (D-2). Such a pattern is scanned as its expansions.
|
|
614
|
-
for (const expanded of slashBraceExpansions(normalized)) {
|
|
615
|
-
const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
|
|
616
|
-
const glob = new Bun.Glob(globPattern)
|
|
617
|
-
// A linked member is a member, as the readdir path above has it:
|
|
618
|
-
// `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
|
|
619
|
-
// `pack*/*` did not (item 987). Followed only where the depth is bounded:
|
|
620
|
-
// under `**` the scan would walk every pnpm `node_modules` link.
|
|
621
|
-
const followSymlinks = !expanded.includes('**')
|
|
622
|
-
try {
|
|
623
|
-
for await (const rel of glob.scan({
|
|
624
|
-
cwd: root,
|
|
625
|
-
onlyFiles: true,
|
|
626
|
-
dot: false,
|
|
627
|
-
followSymlinks,
|
|
628
|
-
})) {
|
|
629
|
-
// Skip nested node_modules — workspace package globs shouldn't
|
|
630
|
-
// ever reach into them, but a pathological pattern like `**`
|
|
631
|
-
// would. Avoid splitting the path on the hot loop.
|
|
632
|
-
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
633
|
-
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
634
|
-
dirs.push(path.dirname(path.resolve(root, rel)))
|
|
635
|
-
}
|
|
636
|
-
} catch (err) {
|
|
637
|
-
// The scan stops at a directory it may not open and cannot skip it,
|
|
638
|
-
// so the readdir path's skip is not on offer here (D-132).
|
|
639
|
-
if (!isPermissionError(err)) throw err
|
|
640
|
-
const where = relPosix(root, path.resolve(root, err.path ?? ''))
|
|
641
|
-
throw new UserError(
|
|
642
|
-
`${where}: not readable by this user (${err.code}), and the workspace glob "${pattern}" walks into it`,
|
|
643
|
-
)
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
return dirs
|
|
218
|
+
/** A directory holding a vx config, and the config file precedence picked there. */
|
|
219
|
+
export interface ConfigDir {
|
|
220
|
+
/** Absolute. */
|
|
221
|
+
readonly dir: string
|
|
222
|
+
readonly configPath: string
|
|
647
223
|
}
|
|
648
224
|
|
|
649
225
|
/**
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
* 43 ms of stats at the last name and ties the one-stat case
|
|
659
|
-
* (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
|
|
660
|
-
* thread-pool round trip apiece).
|
|
661
|
-
* Both answer identically: a directory or dangling link under a config
|
|
662
|
-
* name is skipped for the next name, a symlink to a file counts.
|
|
226
|
+
* Every project directory under `root`, for `vx lock` (a run reads the
|
|
227
|
+
* lock and never walks). In git: `git ls-files` over the config names,
|
|
228
|
+
* tracked and untracked, git's ignore rules applied, and each nested
|
|
229
|
+
* repository's own listing. Outside git: a walk
|
|
230
|
+
* that skips dot-directories and `node_modules`. A config at or under a
|
|
231
|
+
* nested `.vx/workspace.*` belongs to that workspace and is left out.
|
|
232
|
+
* Where a directory holds several, `PROJECT_CONFIG_FILENAMES` order picks
|
|
233
|
+
* the first that is a file.
|
|
663
234
|
*/
|
|
664
|
-
async function
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
235
|
+
export async function findProjectConfigs(root: string): Promise<ConfigDir[]> {
|
|
236
|
+
const rels = (await gitTop(root)) === null ? await scanConfigs(root) : await gitConfigs(root)
|
|
237
|
+
const nested: string[] = []
|
|
238
|
+
for (const rel of rels) {
|
|
239
|
+
const at = rel.lastIndexOf('.vx/workspace.')
|
|
240
|
+
if (at > 0 && rel[at - 1] === '/' && rel.lastIndexOf('/') === at + 3)
|
|
241
|
+
nested.push(rel.slice(0, at - 1))
|
|
242
|
+
}
|
|
243
|
+
const byDir = new Map<string, string[]>()
|
|
244
|
+
for (const rel of rels) {
|
|
245
|
+
const slash = rel.lastIndexOf('/')
|
|
246
|
+
const base = rel.slice(slash + 1)
|
|
247
|
+
if (!CONFIG_RANK.has(base)) continue
|
|
248
|
+
const dir = slash === -1 ? '' : rel.slice(0, slash)
|
|
249
|
+
if (nested.some((n) => dir === n || dir.startsWith(`${n}/`))) continue
|
|
250
|
+
const names = byDir.get(dir)
|
|
251
|
+
if (names === undefined) byDir.set(dir, [base])
|
|
252
|
+
else names.push(base)
|
|
253
|
+
}
|
|
254
|
+
const found = await Promise.all(
|
|
255
|
+
[...byDir].map(async ([rel, names]) => {
|
|
256
|
+
const dir = rel === '' ? root : path.join(root, rel)
|
|
257
|
+
names.sort((a, b) => CONFIG_RANK.get(a)! - CONFIG_RANK.get(b)!)
|
|
258
|
+
// A file each: git lists a tracked config deleted from the worktree,
|
|
259
|
+
// and a directory may carry a config's name.
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
const configPath = path.join(dir, name)
|
|
262
|
+
if (await isFile(configPath)) return { dir, configPath }
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
return found
|
|
268
|
+
.filter((f): f is ConfigDir => f !== null)
|
|
269
|
+
.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
|
|
693
270
|
}
|
|
694
271
|
|
|
695
|
-
const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
|
|
696
|
-
|
|
697
272
|
async function isFile(p: string): Promise<boolean> {
|
|
698
273
|
try {
|
|
699
274
|
return (await stat(p)).isFile()
|
|
700
275
|
} catch (err) {
|
|
701
|
-
|
|
276
|
+
if (isOutOfFds(err)) throw err
|
|
277
|
+
return false
|
|
702
278
|
}
|
|
703
279
|
}
|
|
704
280
|
|
|
705
281
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
282
|
+
* The config and workspace-file paths git lists under `root`, root-relative.
|
|
283
|
+
* A nested repository is one entry to the outer git, a gitlink (a
|
|
284
|
+
* submodule) or an untracked `dir/` (an embedded repository), and none of
|
|
285
|
+
* its files: its own git lists them, or a walk when it has no `.git`, as
|
|
286
|
+
* the input enumeration expands one.
|
|
710
287
|
*/
|
|
711
|
-
function
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
288
|
+
async function gitConfigs(root: string): Promise<string[]> {
|
|
289
|
+
const [configs, index, untracked] = await Promise.all([
|
|
290
|
+
gitZ(root, [
|
|
291
|
+
'ls-files',
|
|
292
|
+
'-z',
|
|
293
|
+
'--cached',
|
|
294
|
+
'--others',
|
|
295
|
+
'--exclude-standard',
|
|
296
|
+
'--',
|
|
297
|
+
':(glob)**/vx.config.*',
|
|
298
|
+
':(glob)**/.vx/workspace.*',
|
|
299
|
+
]),
|
|
300
|
+
gitZ(root, ['ls-files', '-z', '--stage']),
|
|
301
|
+
// An embedded repository is the one untracked entry git lists as `dir/`.
|
|
302
|
+
gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard']),
|
|
303
|
+
])
|
|
304
|
+
// A submodule's `.git` is a file, an embedded repository's a directory.
|
|
305
|
+
const isRepo = (rel: string): Promise<boolean> =>
|
|
306
|
+
stat(path.join(root, rel, '.git')).then(
|
|
729
307
|
() => true,
|
|
730
308
|
() => false,
|
|
731
309
|
)
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
return relPosix(root, dir) || 'the workspace root'
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
|
|
746
|
-
return discoverProjects(workspace)
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* `listProjects`, and `nameless` collects each member directory whose
|
|
751
|
-
* manifest has no `name`: `vx init` names them, since their scripts map to
|
|
752
|
-
* nothing.
|
|
753
|
-
*/
|
|
754
|
-
export async function discoverProjects(
|
|
755
|
-
workspace: Workspace,
|
|
756
|
-
nameless?: string[],
|
|
757
|
-
): Promise<ProjectMeta[]> {
|
|
758
|
-
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
759
|
-
// well; serializing them just stretches the discovery phase by N×.
|
|
760
|
-
const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
|
|
761
|
-
// A root the globs do not list is a project when it holds a vx config
|
|
762
|
-
// (D-39): the package manager's member list stays the manager's, and
|
|
763
|
-
// the root's globs stop at every member as any parent project's do.
|
|
764
|
-
const [perPattern, rootConfig] = await Promise.all([
|
|
765
|
-
Promise.all(positive.map((pattern) => memberDirs(workspace.root, pattern))),
|
|
766
|
-
findConfigFile(workspace.root),
|
|
310
|
+
const gitlinks = index
|
|
311
|
+
.filter((e) => e.startsWith('160000 '))
|
|
312
|
+
.map((e) => e.slice(e.indexOf('\t') + 1))
|
|
313
|
+
const embedded = untracked.filter((e) => e.endsWith('/')).map((e) => e.slice(0, -1))
|
|
314
|
+
const inner = await Promise.all([
|
|
315
|
+
...gitlinks.map(async (rel) =>
|
|
316
|
+
// A submodule never initialised, or vendored with its `.git` removed, is walked.
|
|
317
|
+
(await isRepo(rel)) ? gitConfigs(path.join(root, rel)) : scanConfigs(path.join(root, rel)),
|
|
318
|
+
),
|
|
319
|
+
...embedded.map((rel) => gitConfigs(path.join(root, rel))),
|
|
767
320
|
])
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
matches.add(m)
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (rootConfig !== null) matches.add(workspace.root)
|
|
776
|
-
|
|
777
|
-
// Per-project discovery: the manifest read answers "is there a
|
|
778
|
-
// package.json" (an ENOENT is the "not a member" answer, at the cost of
|
|
779
|
-
// one failed open) and `findConfigFile` answers "which config file", both
|
|
780
|
-
// in flight together. All async and all in flight at once — the thread
|
|
781
|
-
// pool runs them in parallel, and a sync read here measured 2× slower on
|
|
782
|
-
// a 1000-member workspace.
|
|
783
|
-
const loaded = await Promise.all(
|
|
784
|
-
[...matches].map(async (dir) => {
|
|
785
|
-
const pkgJsonPath = dir + path.sep + 'package.json'
|
|
786
|
-
const [configPath, text] = await Promise.all([
|
|
787
|
-
findConfigFile(dir),
|
|
788
|
-
readManifest(workspace.root, dir, pkgJsonPath),
|
|
789
|
-
])
|
|
790
|
-
if (text === null) {
|
|
791
|
-
// A member dir with a vx config and no manifest was skipped without
|
|
792
|
-
// a word: `--all` said no package matched, and a run from inside
|
|
793
|
-
// it "not inside a project" (D-128). The config was found in the
|
|
794
|
-
// same flight as the failed read, so naming it costs nothing.
|
|
795
|
-
if (configPath !== null) {
|
|
796
|
-
process.stderr.write(
|
|
797
|
-
`vx: ${shownDir(workspace.root, dir)} has a vx config but no package.json — skipped: vx names a project by its package.json "name"\n`,
|
|
798
|
-
)
|
|
799
|
-
}
|
|
800
|
-
return null
|
|
801
|
-
}
|
|
802
|
-
const pkg = parsePackageJson(text, pkgJsonPath)
|
|
803
|
-
return { dir, pkg, configPath }
|
|
804
|
-
}),
|
|
805
|
-
)
|
|
321
|
+
const nested = [...gitlinks, ...embedded]
|
|
322
|
+
const prefixed = inner.flatMap((files, i) => files.map((f) => `${nested[i]}/${f}`))
|
|
323
|
+
return [...configs, ...prefixed]
|
|
324
|
+
}
|
|
806
325
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
nameless?.push(dir)
|
|
823
|
-
// A nameless manifest can't be addressed, filtered, or made affected —
|
|
824
|
-
// and vx identifies projects by name, so it simply vanishes. Silent is
|
|
825
|
-
// fine for a dir that declares no tasks; a dir with a vx config was
|
|
826
|
-
// meant to run.
|
|
827
|
-
if (configPath !== null) {
|
|
828
|
-
process.stderr.write(
|
|
829
|
-
`vx: ${shownDir(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
|
|
830
|
-
)
|
|
831
|
-
}
|
|
832
|
-
continue
|
|
833
|
-
}
|
|
834
|
-
const group = byName.get(pkg.name)
|
|
835
|
-
if (group === undefined) byName.set(pkg.name, [entry])
|
|
836
|
-
else group.push(entry)
|
|
326
|
+
/** `git <args>`'s NUL-separated output at `cwd`. */
|
|
327
|
+
async function gitZ(cwd: string, args: string[]): Promise<string[]> {
|
|
328
|
+
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
|
|
329
|
+
try {
|
|
330
|
+
proc = Bun.spawn({
|
|
331
|
+
cmd: [executablePath('git'), ...args],
|
|
332
|
+
cwd,
|
|
333
|
+
env: gitEnv(),
|
|
334
|
+
stdin: 'ignore',
|
|
335
|
+
stdout: 'pipe',
|
|
336
|
+
stderr: 'pipe',
|
|
337
|
+
})
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
|
|
340
|
+
throw err
|
|
837
341
|
}
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
342
|
+
const [out, err, code] = await Promise.all([
|
|
343
|
+
new Response(proc.stdout).bytes(),
|
|
344
|
+
new Response(proc.stderr).text(),
|
|
345
|
+
proc.exited,
|
|
346
|
+
])
|
|
347
|
+
if (code !== 0)
|
|
348
|
+
throw new UserError(`git ls-files failed (exit ${code}): ${err.trim()}`, 'VX_E_GIT')
|
|
349
|
+
return new TextDecoder('utf-8', { ignoreBOM: true })
|
|
350
|
+
.decode(out)
|
|
351
|
+
.split('\0')
|
|
352
|
+
.filter((p) => p !== '')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The config paths under `root` outside git, root-relative: no dot-directory, no `node_modules`. */
|
|
356
|
+
async function scanConfigs(root: string): Promise<string[]> {
|
|
357
|
+
const out: string[] = []
|
|
358
|
+
const walk = async (rel: string): Promise<void> => {
|
|
359
|
+
let entries: Dirent[]
|
|
360
|
+
try {
|
|
361
|
+
entries = await readdir(rel === '' ? root : path.join(root, rel), { withFileTypes: true })
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if (isOutOfFds(err)) throw err
|
|
364
|
+
return
|
|
855
365
|
}
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
// rest of the workspace runs (vite was refused whole, 2026-10-01); one
|
|
866
|
-
// with a vx config was meant to run, and is refused with the way on.
|
|
867
|
-
if (group.some((e) => e.configPath !== null)) {
|
|
868
|
-
throw new UserError(
|
|
869
|
-
`Duplicate package name "${name}" in workspace: ${dirs.join(' and ')}; ` +
|
|
870
|
-
'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
|
|
871
|
-
)
|
|
366
|
+
// A nested workspace's projects are its own.
|
|
367
|
+
if (rel !== '' && entries.some((e) => e.name === '.vx') && (await workspaceFileIn(root, rel)))
|
|
368
|
+
return
|
|
369
|
+
const below: string[] = []
|
|
370
|
+
for (const e of entries) {
|
|
371
|
+
const child = rel === '' ? e.name : `${rel}/${e.name}`
|
|
372
|
+
if (CONFIG_RANK.has(e.name)) out.push(child)
|
|
373
|
+
else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
374
|
+
below.push(child)
|
|
872
375
|
}
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
if (shared.length > 0) {
|
|
876
|
-
shared.sort()
|
|
877
|
-
const shown =
|
|
878
|
-
shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
|
|
879
|
-
process.stderr.write(
|
|
880
|
-
`vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
|
|
881
|
-
)
|
|
376
|
+
await Promise.all(below.map(walk))
|
|
882
377
|
}
|
|
378
|
+
await walk('')
|
|
379
|
+
return out
|
|
380
|
+
}
|
|
883
381
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
382
|
+
async function workspaceFileIn(root: string, rel: string): Promise<boolean> {
|
|
383
|
+
const names = await readdir(path.join(root, rel, '.vx')).catch(() => [] as string[])
|
|
384
|
+
return names.some((n) => n.startsWith('workspace.'))
|
|
888
385
|
}
|
|
889
386
|
|
|
387
|
+
/** How each project got its name, when not by its path: `config`, or the plugin's name. */
|
|
388
|
+
const namedBy = new WeakMap<ProjectMeta, string>()
|
|
389
|
+
|
|
890
390
|
/**
|
|
891
|
-
* The
|
|
892
|
-
*
|
|
893
|
-
*
|
|
391
|
+
* The projects a lock records, by directory: what a run starts from. Named
|
|
392
|
+
* by the locked config's `name`, else by path; a `discover` plugin may name
|
|
393
|
+
* a path-named one after this.
|
|
894
394
|
*/
|
|
895
|
-
|
|
896
|
-
group: T[],
|
|
395
|
+
export function lockedProjects(
|
|
897
396
|
root: string,
|
|
898
|
-
|
|
899
|
-
):
|
|
900
|
-
const
|
|
901
|
-
for (const
|
|
902
|
-
const
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
397
|
+
locked: Readonly<Record<string, { readonly file: string; readonly config: ProjectConfig }>>,
|
|
398
|
+
): ProjectMeta[] {
|
|
399
|
+
const metas: ProjectMeta[] = []
|
|
400
|
+
for (const rel of Object.keys(locked)) {
|
|
401
|
+
const entry = locked[rel]!
|
|
402
|
+
const dir = rel === '.' ? root : path.join(root, rel)
|
|
403
|
+
const name = entry.config.name
|
|
404
|
+
const meta: ProjectMeta = {
|
|
405
|
+
name: name ?? pathName(root, dir),
|
|
406
|
+
dir,
|
|
407
|
+
configPath: path.join(dir, entry.file),
|
|
408
|
+
}
|
|
409
|
+
if (name !== undefined) namedBy.set(meta, 'config')
|
|
410
|
+
metas.push(meta)
|
|
912
411
|
}
|
|
913
|
-
return
|
|
412
|
+
return metas
|
|
914
413
|
}
|
|
915
414
|
|
|
916
415
|
/**
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
* plugin's name
|
|
416
|
+
* The project a plugin's `discover` entry names: a new meta (`added`) for a
|
|
417
|
+
* directory that is not a project yet, else the project already there,
|
|
418
|
+
* renamed when its name was its path's. A config's `name` wins over the
|
|
419
|
+
* plugin's; a name another plugin gave is not changed. `by` names the
|
|
420
|
+
* plugin in a refusal.
|
|
921
421
|
*/
|
|
922
|
-
export async function
|
|
923
|
-
|
|
422
|
+
export async function discoveredProject(
|
|
423
|
+
root: string,
|
|
924
424
|
known: readonly ProjectMeta[],
|
|
925
|
-
|
|
425
|
+
entry: {
|
|
426
|
+
readonly dir: string
|
|
427
|
+
readonly name?: string
|
|
428
|
+
readonly deps?: readonly string[]
|
|
429
|
+
readonly reach?: readonly string[]
|
|
430
|
+
},
|
|
926
431
|
by: string,
|
|
927
|
-
): Promise<ProjectMeta
|
|
432
|
+
): Promise<{ meta: ProjectMeta; added: boolean }> {
|
|
928
433
|
const where = `plugin '${by}' named project`
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
434
|
+
const dirList = (v: unknown): boolean =>
|
|
435
|
+
v === undefined || (Array.isArray(v) && v.every((d) => typeof d === 'string'))
|
|
436
|
+
if (
|
|
437
|
+
typeof entry?.dir !== 'string' ||
|
|
438
|
+
(entry.name !== undefined && (typeof entry.name !== 'string' || entry.name === '')) ||
|
|
439
|
+
!dirList(entry.deps) ||
|
|
440
|
+
!dirList(entry.reach)
|
|
441
|
+
) {
|
|
936
442
|
throw new UserError(
|
|
937
|
-
`${where} ${JSON.stringify(
|
|
443
|
+
`${where} ${JSON.stringify(entry)}: expected { dir: string, name?: string, deps?: string[], reach?: string[] }`,
|
|
938
444
|
)
|
|
939
445
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
)
|
|
446
|
+
const name = entry.name
|
|
447
|
+
if (name !== undefined) {
|
|
448
|
+
const bad = nameFault(name)
|
|
449
|
+
if (bad !== null) throw new UserError(`${where} ${JSON.stringify(name)}: ${bad}`)
|
|
944
450
|
}
|
|
945
|
-
const dir = path.resolve(
|
|
946
|
-
const rel = relPosix(
|
|
451
|
+
const dir = path.resolve(root, entry.dir)
|
|
452
|
+
const rel = relPosix(root, dir)
|
|
453
|
+
const named = name === undefined ? '' : ` "${name}"`
|
|
947
454
|
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
948
|
-
throw new UserError(`${where}
|
|
455
|
+
throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
|
|
949
456
|
}
|
|
950
457
|
const shown = rel === '' ? '.' : rel
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (p.name === named.name) {
|
|
458
|
+
const meta = known.find((p) => p.dir === dir)
|
|
459
|
+
if (meta !== undefined) {
|
|
460
|
+
const from = namedBy.get(meta)
|
|
461
|
+
if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }
|
|
462
|
+
if (from !== undefined) {
|
|
957
463
|
throw new UserError(
|
|
958
|
-
`${where} "${
|
|
464
|
+
`${where} "${name}" at ${shown}: plugin '${from}' already named it "${meta.name}"`,
|
|
959
465
|
)
|
|
960
466
|
}
|
|
467
|
+
meta.name = name
|
|
468
|
+
namedBy.set(meta, by)
|
|
469
|
+
return { meta, added: false }
|
|
961
470
|
}
|
|
962
|
-
const pkgJsonPath = dir + path.sep + 'package.json'
|
|
963
|
-
const [configPath, text] = await Promise.all([
|
|
964
|
-
findConfigFile(dir),
|
|
965
|
-
readManifest(workspace.root, dir, pkgJsonPath),
|
|
966
|
-
])
|
|
967
471
|
if (
|
|
968
|
-
text === null &&
|
|
969
472
|
!(await stat(dir).then(
|
|
970
473
|
(st) => st.isDirectory(),
|
|
971
474
|
() => false,
|
|
972
475
|
))
|
|
973
476
|
) {
|
|
974
|
-
throw new UserError(`${where}
|
|
975
|
-
}
|
|
976
|
-
const parsed: Partial<PackageJson> = text === null ? {} : parsePackageJson(text, pkgJsonPath)
|
|
977
|
-
// A nameless manifest names nothing to disagree with (Nx's own projects).
|
|
978
|
-
const pkg: PackageJson = { ...parsed, name: parsed.name ?? named.name }
|
|
979
|
-
if (pkg.name !== named.name) {
|
|
980
|
-
throw new UserError(
|
|
981
|
-
`${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
|
|
982
|
-
)
|
|
477
|
+
throw new UserError(`${where}${named} at ${shown}: no such directory`)
|
|
983
478
|
}
|
|
984
|
-
|
|
479
|
+
const added: ProjectMeta = { name: name ?? pathName(root, dir), dir, configPath: null }
|
|
480
|
+
if (name !== undefined) namedBy.set(added, by)
|
|
481
|
+
return { meta: added, added: true }
|
|
985
482
|
}
|
|
986
483
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
return
|
|
484
|
+
/** Why `name` cannot name a project, or null. */
|
|
485
|
+
function nameFault(name: string): string | null {
|
|
486
|
+
if (name.trim() !== name) return 'the name has surrounding whitespace'
|
|
487
|
+
// A task is `<project>#<task>` split at the first `#`.
|
|
488
|
+
if (name.includes('#')) return 'the name holds "#" — vx addresses a task as <name>#<task>'
|
|
489
|
+
return null
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Refuses two projects of one name, naming both directories, and a name
|
|
494
|
+
* no task spec can address: a path name holding `#` or surrounding
|
|
495
|
+
* whitespace (set `name`), or `//` on a directory other than the root.
|
|
496
|
+
* Returns `metas` sorted by name.
|
|
497
|
+
*/
|
|
498
|
+
export function checkProjectNames(root: string, metas: ProjectMeta[]): ProjectMeta[] {
|
|
499
|
+
metas.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
500
|
+
for (let i = 0; i < metas.length; i++) {
|
|
501
|
+
const meta = metas[i]!
|
|
502
|
+
const rel = relPosix(root, meta.dir) || '.'
|
|
503
|
+
if (meta.name === ROOT_NAME && rel !== '.') {
|
|
504
|
+
throw new UserError(`${rel}: "//" names the workspace root — set another \`name\``)
|
|
505
|
+
}
|
|
506
|
+
const fault = namedBy.has(meta) ? null : nameFault(meta.name)
|
|
507
|
+
if (fault !== null) {
|
|
508
|
+
throw new UserError(
|
|
509
|
+
`${rel}: a project is named by its path unless named, and ${fault.replace('the name', 'this one')} — set \`name\` in its vx.config`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
const next = metas[i + 1]
|
|
513
|
+
if (next !== undefined && next.name === meta.name) {
|
|
514
|
+
const dirs = [meta, next].map((m) => relPosix(root, m.dir) || '.').sort()
|
|
515
|
+
throw new UserError(
|
|
516
|
+
`two projects are named "${meta.name}": ${dirs[0]} and ${dirs[1]} — give one another \`name\` in its vx.config`,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return metas
|
|
990
521
|
}
|