@vzn/vx 0.0.594 → 0.0.595
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
asTrees,
|
|
29
29
|
isLiteralPattern,
|
|
30
30
|
} from '../util/index.js'
|
|
31
|
-
import { compileNameGlob } from './filter.js'
|
|
31
|
+
import { compileNameGlob, parseFilter } from './filter.js'
|
|
32
32
|
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
33
33
|
|
|
34
34
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
@@ -78,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
|
78
78
|
|
|
79
79
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
80
80
|
const where = `${configPath}: \`cacheRetention\``
|
|
81
|
+
const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
|
|
81
82
|
if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
|
|
82
83
|
throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
|
|
83
84
|
}
|
|
@@ -91,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
91
92
|
olderThan !== undefined &&
|
|
92
93
|
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
93
94
|
) {
|
|
94
|
-
throw new UserError(
|
|
95
|
+
throw new UserError(
|
|
96
|
+
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
|
|
97
|
+
)
|
|
95
98
|
}
|
|
96
99
|
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
97
|
-
throw new UserError(
|
|
100
|
+
throw new UserError(
|
|
101
|
+
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
|
|
102
|
+
)
|
|
98
103
|
}
|
|
99
104
|
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
100
105
|
// every entry, and here it would do so at the end of every run, the
|
|
101
106
|
// entries that run just saved included, so nothing ever hits (item 969).
|
|
102
107
|
if (olderThan !== undefined && parseDuration(olderThan) === 0) {
|
|
103
|
-
throw new UserError(`${
|
|
108
|
+
throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
|
|
104
109
|
}
|
|
105
110
|
if (maxSize !== undefined && parseSize(maxSize) === 0) {
|
|
106
|
-
throw new UserError(`${
|
|
111
|
+
throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
|
|
107
112
|
}
|
|
108
113
|
if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
|
|
109
114
|
throw new UserError(
|
|
110
|
-
`${
|
|
115
|
+
`${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
|
|
111
116
|
)
|
|
112
117
|
}
|
|
113
118
|
}
|
|
@@ -230,6 +235,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
230
235
|
if (plug.commands === null || typeof plug.commands !== 'object') {
|
|
231
236
|
throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
|
|
232
237
|
}
|
|
238
|
+
// A list of commands loaded as the verbs `0`, `1`, … that help listed.
|
|
239
|
+
if (Array.isArray(plug.commands)) {
|
|
240
|
+
throw new UserError(
|
|
241
|
+
`${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
233
244
|
for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
|
|
234
245
|
const c = cmd as { description?: unknown; run?: unknown } | null
|
|
235
246
|
if (
|
|
@@ -345,6 +356,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
345
356
|
) {
|
|
346
357
|
throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
|
|
347
358
|
}
|
|
359
|
+
for (const tag of (tags ?? []) as string[]) {
|
|
360
|
+
const why = tagProblem(tag)
|
|
361
|
+
if (why !== null) {
|
|
362
|
+
throw new UserError(
|
|
363
|
+
`${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
|
|
364
|
+
`name it. Rename the tag.`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
348
368
|
const tasks = config.tasks
|
|
349
369
|
if (tasks === undefined) return
|
|
350
370
|
if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
|
|
@@ -1367,6 +1387,22 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1367
1387
|
: null
|
|
1368
1388
|
}
|
|
1369
1389
|
|
|
1390
|
+
/**
|
|
1391
|
+
* Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
|
|
1392
|
+
* parser itself, so a suffix it learns to read is refused here too: `v1...`
|
|
1393
|
+
* read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
|
|
1394
|
+
*/
|
|
1395
|
+
function tagProblem(tag: string): string | null {
|
|
1396
|
+
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1397
|
+
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1398
|
+
const f = parseFilter(`tag:${tag}`, '/')
|
|
1399
|
+
if (f.gitSince !== undefined) {
|
|
1400
|
+
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
1401
|
+
}
|
|
1402
|
+
if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
|
|
1403
|
+
return null
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1370
1406
|
/**
|
|
1371
1407
|
* Refuse a non-empty glob list made up ENTIRELY of negations.
|
|
1372
1408
|
*
|
|
@@ -1488,6 +1524,12 @@ function assertStringArray(v: unknown, where: string): void {
|
|
|
1488
1524
|
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
1489
1525
|
throw new UserError(`${where} must be an array of non-empty strings`)
|
|
1490
1526
|
}
|
|
1527
|
+
// No path, host or name holds a NUL; a write grant with one reached the
|
|
1528
|
+
// placeholder create as an internal error.
|
|
1529
|
+
const nul = (v as string[]).find((s) => s.includes('\0'))
|
|
1530
|
+
if (nul !== undefined) {
|
|
1531
|
+
throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
|
|
1532
|
+
}
|
|
1491
1533
|
}
|
|
1492
1534
|
|
|
1493
1535
|
/**
|
|
@@ -541,7 +541,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
|
|
|
541
541
|
function lernaPackages(dir: string): string[] | undefined {
|
|
542
542
|
let json: unknown
|
|
543
543
|
try {
|
|
544
|
-
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
|
|
544
|
+
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
545
545
|
} catch {
|
|
546
546
|
return undefined
|
|
547
547
|
}
|
|
@@ -73,7 +73,7 @@ export function buildPackageGraph(
|
|
|
73
73
|
const dev = bucket(p.packageJson.devDependencies)
|
|
74
74
|
const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
|
|
75
75
|
if (typeof spec !== 'string') return
|
|
76
|
-
const target = linked(key, spec.trim(), p.dir, installed)
|
|
76
|
+
const target = linked(key, fromCatalog(p, key, spec.trim()), p.dir, installed)
|
|
77
77
|
if (target !== undefined && target !== p.name) into.add(target)
|
|
78
78
|
}
|
|
79
79
|
// Which entry is installed when a key sits in more than one field,
|
|
@@ -300,6 +300,19 @@ export function buildPackageGraph(
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The spec a `catalog:` / `catalog:<name>` entry stands for in the
|
|
305
|
+
* workspace's catalog, so it is an edge by the rule its range or protocol
|
|
306
|
+
* meets (zenstack's `nuxt: "catalog:"` is `4.3.1`, which the workspace's
|
|
307
|
+
* versionless `nuxt` sample does not satisfy). A catalog the graph was not
|
|
308
|
+
* given or that lacks the key leaves the spec, which keeps its edge.
|
|
309
|
+
*/
|
|
310
|
+
function fromCatalog(p: ProjectMeta, key: string, spec: string): string {
|
|
311
|
+
if (p.catalogs === undefined || !spec.startsWith('catalog:')) return spec
|
|
312
|
+
const resolved = p.catalogs.get(spec.slice('catalog:'.length).trim() || 'default')?.[key]
|
|
313
|
+
return typeof resolved === 'string' && !resolved.startsWith('catalog:') ? resolved.trim() : spec
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
function bucket(field: unknown): Readonly<Record<string, unknown>> {
|
|
304
317
|
return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
|
|
305
318
|
}
|
|
@@ -377,8 +390,7 @@ function linkedTarget(
|
|
|
377
390
|
if (!MAY_POINT_ELSEWHERE.test(spec)) {
|
|
378
391
|
const local = byName.get(key)
|
|
379
392
|
if (local === undefined) return undefined
|
|
380
|
-
// A catalog entry's
|
|
381
|
-
// (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
|
|
393
|
+
// A catalog entry its workspace's catalog did not resolve keeps the edge.
|
|
382
394
|
if (!installed || spec.startsWith('catalog:')) return key
|
|
383
395
|
return satisfies(local, spec) ? key : undefined
|
|
384
396
|
}
|
|
@@ -479,6 +479,7 @@ export async function loadProjectConfigs(
|
|
|
479
479
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
480
480
|
let cwd = ''
|
|
481
481
|
let umask = -1
|
|
482
|
+
let unredirect: (() => void) | undefined
|
|
482
483
|
// More than one evaluation in flight: a change seen after one load may
|
|
483
484
|
// be another's.
|
|
484
485
|
let overlapping = false
|
|
@@ -588,6 +589,7 @@ export async function loadProjectConfigs(
|
|
|
588
589
|
else misses.push(i)
|
|
589
590
|
}
|
|
590
591
|
if (misses.length > 0) {
|
|
592
|
+
unredirect = stdoutToStderr()
|
|
591
593
|
builtins = builtinSnapshot()
|
|
592
594
|
env = { ...process.env }
|
|
593
595
|
cwd = process.cwd()
|
|
@@ -651,6 +653,7 @@ export async function loadProjectConfigs(
|
|
|
651
653
|
)
|
|
652
654
|
return results.map((r) => (r as Loaded).config)
|
|
653
655
|
} finally {
|
|
656
|
+
unredirect?.()
|
|
654
657
|
endRound()
|
|
655
658
|
if (store !== undefined && !tainted) {
|
|
656
659
|
if (evals.length > 0) {
|
|
@@ -665,6 +668,72 @@ export async function loadProjectConfigs(
|
|
|
665
668
|
}
|
|
666
669
|
}
|
|
667
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Every route to fd 1 sent to stderr while configs evaluate in this
|
|
673
|
+
* process; returns the release. A verb's stdout is its output (`vx show
|
|
674
|
+
* --format json`), and a config's `console.log` came out ahead of the
|
|
675
|
+
* JSON. The worker does the same for a repeat load (D-64), `vx mcp` for
|
|
676
|
+
* its whole serve. Taken before a round's built-in snapshot and released
|
|
677
|
+
* after its check, so the guard sees no change of vx's own.
|
|
678
|
+
*
|
|
679
|
+
* Counted, because rounds overlap (`--affected`'s per-file sweep loads each
|
|
680
|
+
* config in a round of its own): a second install over the first moved
|
|
681
|
+
* `Bun.write` and `console` under the first round's snapshot, which refused
|
|
682
|
+
* the loads, and the undos ran out of order and left a redirect in place
|
|
683
|
+
* for good. The first taker installs; the last release restores.
|
|
684
|
+
*/
|
|
685
|
+
let redirectHolders = 0
|
|
686
|
+
let undoRedirect = (): void => {}
|
|
687
|
+
/**
|
|
688
|
+
* Bun's own `Console`, taken at load: the console a round finds may be a
|
|
689
|
+
* constructed one (`vx mcp` serves under its own), which has no `Console`.
|
|
690
|
+
* The global's, not `node:console`'s: the playground bundles this module
|
|
691
|
+
* for the browser, where that specifier is a stub.
|
|
692
|
+
*/
|
|
693
|
+
const BunConsole = (globalThis.console as { Console?: typeof console.Console }).Console
|
|
694
|
+
|
|
695
|
+
function stdoutToStderr(): () => void {
|
|
696
|
+
if (redirectHolders++ === 0) undoRedirect = installRedirect()
|
|
697
|
+
return () => {
|
|
698
|
+
if (--redirectHolders === 0) undoRedirect()
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function installRedirect(): () => void {
|
|
703
|
+
const out = process.stdout
|
|
704
|
+
const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
|
|
705
|
+
const ownConsole = globalThis.console
|
|
706
|
+
const bun = Bun as { write: typeof Bun.write }
|
|
707
|
+
const ownBunWrite = Bun.write
|
|
708
|
+
const ownWriter = Object.getOwnPropertyDescriptor(Bun.stdout, 'writer')
|
|
709
|
+
out.write = ((...args: Parameters<typeof process.stderr.write>) =>
|
|
710
|
+
process.stderr.write(...args)) as typeof out.write
|
|
711
|
+
bun.write = ((dest: unknown, ...rest: unknown[]) =>
|
|
712
|
+
(ownBunWrite as (...a: unknown[]) => Promise<number>)(
|
|
713
|
+
dest === Bun.stdout ? Bun.stderr : dest,
|
|
714
|
+
...rest,
|
|
715
|
+
)) as typeof Bun.write
|
|
716
|
+
;(Bun.stdout as { writer: typeof Bun.stdout.writer }).writer = ((
|
|
717
|
+
...args: Parameters<typeof Bun.stderr.writer>
|
|
718
|
+
) => Bun.stderr.writer(...args)) as typeof Bun.stdout.writer
|
|
719
|
+
// Bun's console adds `write`, which a constructed Console lacks.
|
|
720
|
+
globalThis.console = Object.assign(new BunConsole!(process.stderr, process.stderr), {
|
|
721
|
+
write: (...data: string[]) => {
|
|
722
|
+
const text = data.join('')
|
|
723
|
+
process.stderr.write(text)
|
|
724
|
+
return text.length
|
|
725
|
+
},
|
|
726
|
+
})
|
|
727
|
+
return () => {
|
|
728
|
+
if (ownWrite === undefined) delete (out as { write?: unknown }).write
|
|
729
|
+
else Object.defineProperty(out, 'write', ownWrite)
|
|
730
|
+
bun.write = ownBunWrite
|
|
731
|
+
if (ownWriter === undefined) delete (Bun.stdout as { writer?: unknown }).writer
|
|
732
|
+
else Object.defineProperty(Bun.stdout, 'writer', ownWriter)
|
|
733
|
+
globalThis.console = ownConsole
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
668
737
|
/**
|
|
669
738
|
* The built-in prototypes a config's object is read through. A config that
|
|
670
739
|
* set `Object.prototype.exec` gave every other project's task that
|
|
@@ -751,6 +820,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
|
|
|
751
820
|
'sleep',
|
|
752
821
|
'spawn',
|
|
753
822
|
'spawnSync',
|
|
823
|
+
'stderr',
|
|
754
824
|
'stdout',
|
|
755
825
|
'stringWidth',
|
|
756
826
|
'stripANSI',
|
|
@@ -1039,6 +1109,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1039
1109
|
// left out: it loads first in every run, filtered or not, so none
|
|
1040
1110
|
// depends on what else loaded (D-122's case), and plugins' tests and
|
|
1041
1111
|
// tools hand state through them.
|
|
1112
|
+
const unredirect = stdoutToStderr()
|
|
1042
1113
|
const builtins = builtinSnapshot('globalThis', true)
|
|
1043
1114
|
const env = { ...process.env }
|
|
1044
1115
|
const cwd = process.cwd()
|
|
@@ -1053,6 +1124,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1053
1124
|
...restoreCwd(cwd),
|
|
1054
1125
|
...restoreUmask(umask),
|
|
1055
1126
|
]
|
|
1127
|
+
unredirect()
|
|
1056
1128
|
// eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
|
|
1057
1129
|
if (changed.length > 0) throw builtinsChanged(changed, configPath)
|
|
1058
1130
|
}
|
|
@@ -31,8 +31,18 @@ export interface Workspace {
|
|
|
31
31
|
root: string
|
|
32
32
|
/** Glob patterns relative to root that match project directories. */
|
|
33
33
|
packageGlobs: string[]
|
|
34
|
+
/** The package manager's catalogs, when the root declares any. */
|
|
35
|
+
catalogs?: Catalogs
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Catalog name → dependency → the spec a `catalog:<name>` entry stands
|
|
40
|
+
* for; `catalog:` alone is `default`. pnpm's from `pnpm-workspace.yaml`
|
|
41
|
+
* (`catalog`, `catalogs`), bun's from the root `package.json` (the same
|
|
42
|
+
* keys, top level or under `workspaces`).
|
|
43
|
+
*/
|
|
44
|
+
export type Catalogs = ReadonlyMap<string, Readonly<Record<string, unknown>>>
|
|
45
|
+
|
|
36
46
|
export interface ProjectMeta {
|
|
37
47
|
/** Canonical name from package.json. */
|
|
38
48
|
name: string
|
|
@@ -41,6 +51,8 @@ export interface ProjectMeta {
|
|
|
41
51
|
packageJson: PackageJson
|
|
42
52
|
/** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
|
|
43
53
|
configPath: string | null
|
|
54
|
+
/** The workspace's catalogs, which the package graph resolves `catalog:` specs through. */
|
|
55
|
+
catalogs?: Catalogs
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/** A discovered project joined with its loaded vx config. */
|
|
@@ -168,9 +180,12 @@ async function claimsMember(
|
|
|
168
180
|
const normalized = pattern.replace(/\/+$/, '')
|
|
169
181
|
// `.` means the root itself is the project — never a directory below it.
|
|
170
182
|
if (normalized === '' || normalized === '.') continue
|
|
171
|
-
|
|
183
|
+
// Matched as discovery scans, on the manifest: `packages/**` lists
|
|
184
|
+
// `packages/package.json`, while against the directory `**` needs a
|
|
185
|
+
// segment below `packages`, and a run from there was its own root.
|
|
186
|
+
const glob = new Bun.Glob(`${normalized}/package.json`)
|
|
172
187
|
for (const rel of rels) {
|
|
173
|
-
if (!glob.match(rel)) continue
|
|
188
|
+
if (!glob.match(`${rel}/package.json`)) continue
|
|
174
189
|
// `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
|
|
175
190
|
// which discovery skips, and a run from inside it found a workspace
|
|
176
191
|
// that does not list it. Such a path asks discovery's own walk.
|
|
@@ -231,17 +246,30 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
|
|
|
231
246
|
* the root itself is the only project, hence `['.']`.
|
|
232
247
|
*/
|
|
233
248
|
async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
|
|
249
|
+
return (await readRootManifests(dir, reads)).globs
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `readPackageGlobs`, and the catalogs the same manifests declare. */
|
|
253
|
+
async function readRootManifests(
|
|
254
|
+
dir: string,
|
|
255
|
+
reads?: LoadReads,
|
|
256
|
+
): Promise<{ globs: string[] | null; catalogs: Catalogs | undefined }> {
|
|
234
257
|
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
235
258
|
const yaml = await readOnce(reads, yamlPath)
|
|
236
259
|
let yamlWithoutPackages = false
|
|
260
|
+
// pnpm reads its catalogs from this file alone, bun from package.json.
|
|
261
|
+
let catalogs: Catalogs | undefined
|
|
237
262
|
if (yaml !== null) {
|
|
238
263
|
const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
|
|
239
264
|
if (parsed !== null && parsed !== undefined) {
|
|
240
265
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
241
266
|
throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
|
|
242
267
|
}
|
|
268
|
+
catalogs = catalogsOf([parsed])
|
|
243
269
|
const packages = (parsed as { packages?: unknown }).packages
|
|
244
|
-
if (packages !== undefined)
|
|
270
|
+
if (packages !== undefined) {
|
|
271
|
+
return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
|
|
272
|
+
}
|
|
245
273
|
}
|
|
246
274
|
// No `packages:`: pnpm 10 keeps its settings and catalogs in this file
|
|
247
275
|
// for a single-package repo too. The root's package.json decides, as it
|
|
@@ -251,18 +279,46 @@ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[
|
|
|
251
279
|
}
|
|
252
280
|
const pkgPath = path.join(dir, 'package.json')
|
|
253
281
|
const pkgBytes = await readOnce(reads, pkgPath)
|
|
254
|
-
if (pkgBytes === null) return yamlWithoutPackages ? [] : null
|
|
282
|
+
if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
|
|
255
283
|
const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
|
|
256
284
|
const ws = pkg.workspaces as unknown
|
|
257
|
-
if (
|
|
285
|
+
if (yaml === null) {
|
|
286
|
+
catalogs = catalogsOf(
|
|
287
|
+
ws !== null && typeof ws === 'object' && !Array.isArray(ws) ? [pkg, ws] : [pkg],
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
if (ws === undefined || ws === null) return { globs: ['.'], catalogs }
|
|
258
291
|
if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
|
|
259
|
-
|
|
292
|
+
const globs = assertGlobList(
|
|
260
293
|
(ws as { packages?: unknown }).packages ?? [],
|
|
261
294
|
pkgPath,
|
|
262
295
|
'workspaces.packages',
|
|
263
296
|
)
|
|
297
|
+
return { globs, catalogs }
|
|
264
298
|
}
|
|
265
|
-
return assertGlobList(ws, pkgPath, 'workspaces')
|
|
299
|
+
return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `catalog` and `catalogs` keys of the manifests (bun reads them at
|
|
304
|
+
* the top level and under `workspaces`), or undefined when none declares
|
|
305
|
+
* one. An entry that is not a mapping is no catalog: a `catalog:` spec
|
|
306
|
+
* through it keeps its edge, as one through an unknown catalog does.
|
|
307
|
+
*/
|
|
308
|
+
function catalogsOf(manifests: readonly object[]): Catalogs | undefined {
|
|
309
|
+
const isMap = (v: unknown): v is Record<string, unknown> =>
|
|
310
|
+
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
311
|
+
let out: Map<string, Readonly<Record<string, unknown>>> | undefined
|
|
312
|
+
for (const m of manifests) {
|
|
313
|
+
const { catalog, catalogs } = m as { catalog?: unknown; catalogs?: unknown }
|
|
314
|
+
if (isMap(catalogs)) {
|
|
315
|
+
for (const [name, c] of Object.entries(catalogs))
|
|
316
|
+
if (isMap(c)) (out ??= new Map()).set(name, c)
|
|
317
|
+
}
|
|
318
|
+
// `catalog` is the default catalog to pnpm and bun.
|
|
319
|
+
if (isMap(catalog)) (out ??= new Map()).set('default', catalog)
|
|
320
|
+
}
|
|
321
|
+
return out
|
|
266
322
|
}
|
|
267
323
|
|
|
268
324
|
/**
|
|
@@ -435,14 +491,14 @@ const repoIds = new Map<string, Promise<string | null>>()
|
|
|
435
491
|
* itself is treated as a single-project workspace.
|
|
436
492
|
*/
|
|
437
493
|
export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
|
|
438
|
-
const packageGlobs = await
|
|
494
|
+
const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
|
|
439
495
|
if (packageGlobs === null) {
|
|
440
496
|
// The CLI never lands here (findWorkspaceRoot returns only a dir that
|
|
441
497
|
// passes one of the two checks); a direct call of this public function
|
|
442
498
|
// on a bare dir does (D-58).
|
|
443
499
|
throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
|
|
444
500
|
}
|
|
445
|
-
return { root, packageGlobs }
|
|
501
|
+
return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
|
|
446
502
|
}
|
|
447
503
|
|
|
448
504
|
// `<dir>/*` — the shape of nearly every workspace glob. Anything with another
|
|
@@ -720,6 +776,16 @@ export async function discoverProjects(
|
|
|
720
776
|
for (const entry of loaded) {
|
|
721
777
|
if (entry === null) continue
|
|
722
778
|
const { dir, pkg, configPath } = entry
|
|
779
|
+
// npm and pnpm take `../ext/*`, but `--affected` asks git from the root
|
|
780
|
+
// and sees nothing outside it: an edit there moved the task's key and
|
|
781
|
+
// selected nothing, green.
|
|
782
|
+
const rel = relPosix(workspace.root, dir)
|
|
783
|
+
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
784
|
+
throw new UserError(
|
|
785
|
+
`workspace member ${rel} (${dir}) is outside the workspace root ${workspace.root}: ` +
|
|
786
|
+
'vx keeps every project under the root. Move the workspace root up to a directory that holds every member.',
|
|
787
|
+
)
|
|
788
|
+
}
|
|
723
789
|
if (!pkg.name) {
|
|
724
790
|
nameless?.push(dir)
|
|
725
791
|
// A nameless manifest can't be addressed, filtered, or made affected —
|
|
@@ -753,7 +819,7 @@ export async function discoverProjects(
|
|
|
753
819
|
}
|
|
754
820
|
if (group.length === 1) {
|
|
755
821
|
const { dir, pkg, configPath } = group[0]!
|
|
756
|
-
projects.push({ name, dir, packageJson: pkg, configPath })
|
|
822
|
+
projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
|
|
757
823
|
continue
|
|
758
824
|
}
|
|
759
825
|
// The root's own manifest is '' relative to itself, and the refusal read
|
|
@@ -884,5 +950,10 @@ export async function namedProject(
|
|
|
884
950
|
`${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
|
|
885
951
|
)
|
|
886
952
|
}
|
|
887
|
-
return { name: named.name, dir, packageJson: pkg, configPath }
|
|
953
|
+
return withCatalogs({ name: named.name, dir, packageJson: pkg, configPath }, workspace)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function withCatalogs(meta: ProjectMeta, workspace: Workspace): ProjectMeta {
|
|
957
|
+
if (workspace.catalogs !== undefined) meta.catalogs = workspace.catalogs
|
|
958
|
+
return meta
|
|
888
959
|
}
|