@vzn/vx 0.0.591 → 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/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- 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 +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- package/src/workspace/workspace.ts +121 -14
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
cacheTodo,
|
|
42
42
|
pruneOrphanPersistentNotes,
|
|
43
43
|
} from './migration.js'
|
|
44
|
+
import { relPosix } from '../util/index.js'
|
|
44
45
|
|
|
45
46
|
// `lint` is not here: a linter reads sources, and an edge to `build`
|
|
46
47
|
// serialises the two for nothing (the init walkthrough, 2026-09-04).
|
|
@@ -127,12 +128,26 @@ function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
|
|
|
127
128
|
: manager === 'npm'
|
|
128
129
|
? !set('ignore-scripts', 'true')
|
|
129
130
|
: manager === 'pnpm'
|
|
130
|
-
? !set('enable-pre-post-scripts', 'false') &&
|
|
131
|
-
!/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
|
|
131
|
+
? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
|
|
132
132
|
: true
|
|
133
133
|
return runs ? manager : null
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
|
|
138
|
+
* it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
|
|
139
|
+
* which pnpm 10 honours, and folded hooks pnpm does not run.
|
|
140
|
+
*/
|
|
141
|
+
function pnpmSkipsHooks(yaml: string | undefined): boolean {
|
|
142
|
+
if (yaml === undefined) return false
|
|
143
|
+
try {
|
|
144
|
+
const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
|
|
145
|
+
return parsed?.enablePrePostScripts === false
|
|
146
|
+
} catch {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
/** The package manager that owns a directory, and the directory that says so. */
|
|
137
152
|
interface Owner {
|
|
138
153
|
manager: string
|
|
@@ -155,8 +170,10 @@ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
|
|
|
155
170
|
}
|
|
156
171
|
let manager: string | undefined
|
|
157
172
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
// A byte-order mark is stripped, as npm, pnpm and discovery strip it:
|
|
174
|
+
// JSON.parse threw on one and the field went unread.
|
|
175
|
+
const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
|
|
176
|
+
const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
|
|
160
177
|
// A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
|
|
161
178
|
// hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
|
|
162
179
|
// claim nothing had checked (D-96).
|
|
@@ -524,7 +541,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
|
|
|
524
541
|
function lernaPackages(dir: string): string[] | undefined {
|
|
525
542
|
let json: unknown
|
|
526
543
|
try {
|
|
527
|
-
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
|
|
544
|
+
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
528
545
|
} catch {
|
|
529
546
|
return undefined
|
|
530
547
|
}
|
|
@@ -789,7 +806,7 @@ export function migrateScripts(
|
|
|
789
806
|
} else if (clash !== undefined && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
790
807
|
const would = unnamedMaps()
|
|
791
808
|
notes.push(
|
|
792
|
-
`${clash.name} (the workspace root) not mapped: ${
|
|
809
|
+
`${clash.name} (the workspace root) not mapped: ${relPosix(outsideDir, clash.dir)} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
|
|
793
810
|
)
|
|
794
811
|
} else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
795
812
|
// react's nameless root: "its scripts run the workspace" was not why,
|
|
@@ -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
|
}
|
|
@@ -269,6 +269,9 @@ interface BuildPosition {
|
|
|
269
269
|
column?: number
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/** A stack frame naming a file path, as a config's own throw has. */
|
|
273
|
+
const NAMED_FRAME = /\n\s+at .*[\\/]/
|
|
274
|
+
|
|
272
275
|
/**
|
|
273
276
|
* Turn the two errors Bun's own loader throws into user errors naming the
|
|
274
277
|
* file the user wrote; every other throw is the config's own and already
|
|
@@ -301,6 +304,13 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
|
|
|
301
304
|
return configLoadError(errors[0], configPath, kind)
|
|
302
305
|
}
|
|
303
306
|
if (typeof message !== 'string') return null
|
|
307
|
+
// Bun's JSON loader throws a `SyntaxError` whose stack, when it has one,
|
|
308
|
+
// holds no file frame: a malformed `import data from './data.json'`
|
|
309
|
+
// printed `vx: JSON Parse error: Expected '}'` and named nothing. A
|
|
310
|
+
// config's own `JSON.parse` has its line in the stack and passes through.
|
|
311
|
+
if (name === 'SyntaxError' && !NAMED_FRAME.test(String((err as { stack?: unknown }).stack))) {
|
|
312
|
+
return new UserError(`${kind} config ${configPath}: an import does not parse: ${message}`)
|
|
313
|
+
}
|
|
304
314
|
if (name === 'ResolveMessage') {
|
|
305
315
|
const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
|
|
306
316
|
const what = spec === undefined ? message.replace(BUST_QUERY, '') : `cannot find '${spec}'`
|
|
@@ -469,6 +479,7 @@ export async function loadProjectConfigs(
|
|
|
469
479
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
470
480
|
let cwd = ''
|
|
471
481
|
let umask = -1
|
|
482
|
+
let unredirect: (() => void) | undefined
|
|
472
483
|
// More than one evaluation in flight: a change seen after one load may
|
|
473
484
|
// be another's.
|
|
474
485
|
let overlapping = false
|
|
@@ -578,6 +589,7 @@ export async function loadProjectConfigs(
|
|
|
578
589
|
else misses.push(i)
|
|
579
590
|
}
|
|
580
591
|
if (misses.length > 0) {
|
|
592
|
+
unredirect = stdoutToStderr()
|
|
581
593
|
builtins = builtinSnapshot()
|
|
582
594
|
env = { ...process.env }
|
|
583
595
|
cwd = process.cwd()
|
|
@@ -641,6 +653,7 @@ export async function loadProjectConfigs(
|
|
|
641
653
|
)
|
|
642
654
|
return results.map((r) => (r as Loaded).config)
|
|
643
655
|
} finally {
|
|
656
|
+
unredirect?.()
|
|
644
657
|
endRound()
|
|
645
658
|
if (store !== undefined && !tainted) {
|
|
646
659
|
if (evals.length > 0) {
|
|
@@ -655,6 +668,72 @@ export async function loadProjectConfigs(
|
|
|
655
668
|
}
|
|
656
669
|
}
|
|
657
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
|
+
|
|
658
737
|
/**
|
|
659
738
|
* The built-in prototypes a config's object is read through. A config that
|
|
660
739
|
* set `Object.prototype.exec` gave every other project's task that
|
|
@@ -665,14 +744,19 @@ export async function loadProjectConfigs(
|
|
|
665
744
|
*/
|
|
666
745
|
// `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
|
|
667
746
|
// => 7n` gave every task the key 00000000, and a changed command replayed
|
|
668
|
-
// the old output (D-75).
|
|
747
|
+
// the old output (D-75). `Bun` is read by its identifier, not through
|
|
748
|
+
// globalThis: the docs playground bundles this file with the identifier
|
|
749
|
+
// rewritten to its shim, and a browser has no global `Bun`.
|
|
669
750
|
const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
|
|
670
751
|
(name) =>
|
|
671
752
|
[
|
|
672
753
|
name,
|
|
673
754
|
name
|
|
674
755
|
.split('.')
|
|
675
|
-
.reduce<unknown>(
|
|
756
|
+
.reduce<unknown>(
|
|
757
|
+
(o, k) => (o === globalThis && k === 'Bun' ? Bun : (o as Record<string, unknown>)[k]),
|
|
758
|
+
globalThis,
|
|
759
|
+
) as object,
|
|
676
760
|
] as const,
|
|
677
761
|
)
|
|
678
762
|
|
|
@@ -736,6 +820,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
|
|
|
736
820
|
'sleep',
|
|
737
821
|
'spawn',
|
|
738
822
|
'spawnSync',
|
|
823
|
+
'stderr',
|
|
739
824
|
'stdout',
|
|
740
825
|
'stringWidth',
|
|
741
826
|
'stripANSI',
|
|
@@ -1024,6 +1109,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1024
1109
|
// left out: it loads first in every run, filtered or not, so none
|
|
1025
1110
|
// depends on what else loaded (D-122's case), and plugins' tests and
|
|
1026
1111
|
// tools hand state through them.
|
|
1112
|
+
const unredirect = stdoutToStderr()
|
|
1027
1113
|
const builtins = builtinSnapshot('globalThis', true)
|
|
1028
1114
|
const env = { ...process.env }
|
|
1029
1115
|
const cwd = process.cwd()
|
|
@@ -1038,6 +1124,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1038
1124
|
...restoreCwd(cwd),
|
|
1039
1125
|
...restoreUmask(umask),
|
|
1040
1126
|
]
|
|
1127
|
+
unredirect()
|
|
1041
1128
|
// eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
|
|
1042
1129
|
if (changed.length > 0) throw builtinsChanged(changed, configPath)
|
|
1043
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. */
|
|
@@ -115,8 +127,12 @@ export async function findWorkspaceRoot(
|
|
|
115
127
|
// them: `packages/**` holding `packages/inner` (with `workspaces:
|
|
116
128
|
// ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
|
|
117
129
|
// `inner` to the outer root, two roots and two keys for one tree.
|
|
118
|
-
if (
|
|
119
|
-
|
|
130
|
+
if (
|
|
131
|
+
(await claimsMember(dir, [claimed], globs)) &&
|
|
132
|
+
(await claimsMember(dir, members, globs))
|
|
133
|
+
)
|
|
134
|
+
claimed = dir
|
|
135
|
+
} else if (await claimsMember(dir, inner === null ? below : [inner], globs)) {
|
|
120
136
|
claimed = dir
|
|
121
137
|
members = inner === null ? [...below] : [inner]
|
|
122
138
|
}
|
|
@@ -152,7 +168,11 @@ export async function findWorkspaceRoot(
|
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
/** True when one of `below` (dirs under `root`, toward `start`) is a member. */
|
|
155
|
-
function claimsMember(
|
|
171
|
+
async function claimsMember(
|
|
172
|
+
root: string,
|
|
173
|
+
below: readonly string[],
|
|
174
|
+
globs: readonly string[],
|
|
175
|
+
): Promise<boolean> {
|
|
156
176
|
if (below.length === 0 || globs.length === 0) return false
|
|
157
177
|
const { positive, negative } = splitPackageGlobs(globs)
|
|
158
178
|
const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
|
|
@@ -160,12 +180,25 @@ function claimsMember(root: string, below: readonly string[], globs: readonly st
|
|
|
160
180
|
const normalized = pattern.replace(/\/+$/, '')
|
|
161
181
|
// `.` means the root itself is the project — never a directory below it.
|
|
162
182
|
if (normalized === '' || normalized === '.') continue
|
|
163
|
-
|
|
164
|
-
|
|
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`)
|
|
187
|
+
for (const rel of rels) {
|
|
188
|
+
if (!glob.match(`${rel}/package.json`)) continue
|
|
189
|
+
// `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
|
|
190
|
+
// which discovery skips, and a run from inside it found a workspace
|
|
191
|
+
// that does not list it. Such a path asks discovery's own walk.
|
|
192
|
+
if (!SKIPPED_SEGMENT.test(rel)) return true
|
|
193
|
+
const dirs = await memberDirs(root, pattern)
|
|
194
|
+
if (dirs.includes(path.join(root, rel))) return true
|
|
195
|
+
}
|
|
165
196
|
}
|
|
166
197
|
return false
|
|
167
198
|
}
|
|
168
199
|
|
|
200
|
+
const SKIPPED_SEGMENT = /(^|\/)(\.|node_modules(\/|$))/
|
|
201
|
+
|
|
169
202
|
/**
|
|
170
203
|
* pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
|
|
171
204
|
* list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
|
|
@@ -213,17 +246,30 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
|
|
|
213
246
|
* the root itself is the only project, hence `['.']`.
|
|
214
247
|
*/
|
|
215
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 }> {
|
|
216
257
|
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
217
258
|
const yaml = await readOnce(reads, yamlPath)
|
|
218
259
|
let yamlWithoutPackages = false
|
|
260
|
+
// pnpm reads its catalogs from this file alone, bun from package.json.
|
|
261
|
+
let catalogs: Catalogs | undefined
|
|
219
262
|
if (yaml !== null) {
|
|
220
263
|
const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
|
|
221
264
|
if (parsed !== null && parsed !== undefined) {
|
|
222
265
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
223
266
|
throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
|
|
224
267
|
}
|
|
268
|
+
catalogs = catalogsOf([parsed])
|
|
225
269
|
const packages = (parsed as { packages?: unknown }).packages
|
|
226
|
-
if (packages !== undefined)
|
|
270
|
+
if (packages !== undefined) {
|
|
271
|
+
return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
|
|
272
|
+
}
|
|
227
273
|
}
|
|
228
274
|
// No `packages:`: pnpm 10 keeps its settings and catalogs in this file
|
|
229
275
|
// for a single-package repo too. The root's package.json decides, as it
|
|
@@ -233,18 +279,46 @@ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[
|
|
|
233
279
|
}
|
|
234
280
|
const pkgPath = path.join(dir, 'package.json')
|
|
235
281
|
const pkgBytes = await readOnce(reads, pkgPath)
|
|
236
|
-
if (pkgBytes === null) return yamlWithoutPackages ? [] : null
|
|
282
|
+
if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
|
|
237
283
|
const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
|
|
238
284
|
const ws = pkg.workspaces as unknown
|
|
239
|
-
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 }
|
|
240
291
|
if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
|
|
241
|
-
|
|
292
|
+
const globs = assertGlobList(
|
|
242
293
|
(ws as { packages?: unknown }).packages ?? [],
|
|
243
294
|
pkgPath,
|
|
244
295
|
'workspaces.packages',
|
|
245
296
|
)
|
|
297
|
+
return { globs, catalogs }
|
|
298
|
+
}
|
|
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)
|
|
246
320
|
}
|
|
247
|
-
return
|
|
321
|
+
return out
|
|
248
322
|
}
|
|
249
323
|
|
|
250
324
|
/**
|
|
@@ -374,6 +448,11 @@ function extglobRefusal(pattern: string, file: string, field: string): UserError
|
|
|
374
448
|
*/
|
|
375
449
|
export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
|
|
376
450
|
const rel = config?.cacheDir ?? (process.env['VX_CACHE_DIR'] || path.join('.vx', 'cache'))
|
|
451
|
+
// No shell expands `~` in a config string or a quoted variable, and
|
|
452
|
+
// `'~/.cache/vx'` made a directory named `~` in the workspace.
|
|
453
|
+
if (rel.startsWith('~/')) {
|
|
454
|
+
return path.join(process.env['HOME'] || homedir(), rel.slice(1))
|
|
455
|
+
}
|
|
377
456
|
return path.resolve(root, rel)
|
|
378
457
|
}
|
|
379
458
|
|
|
@@ -412,14 +491,14 @@ const repoIds = new Map<string, Promise<string | null>>()
|
|
|
412
491
|
* itself is treated as a single-project workspace.
|
|
413
492
|
*/
|
|
414
493
|
export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
|
|
415
|
-
const packageGlobs = await
|
|
494
|
+
const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
|
|
416
495
|
if (packageGlobs === null) {
|
|
417
496
|
// The CLI never lands here (findWorkspaceRoot returns only a dir that
|
|
418
497
|
// passes one of the two checks); a direct call of this public function
|
|
419
498
|
// on a bare dir does (D-58).
|
|
420
499
|
throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
|
|
421
500
|
}
|
|
422
|
-
return { root, packageGlobs }
|
|
501
|
+
return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
|
|
423
502
|
}
|
|
424
503
|
|
|
425
504
|
// `<dir>/*` — the shape of nearly every workspace glob. Anything with another
|
|
@@ -697,6 +776,16 @@ export async function discoverProjects(
|
|
|
697
776
|
for (const entry of loaded) {
|
|
698
777
|
if (entry === null) continue
|
|
699
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
|
+
}
|
|
700
789
|
if (!pkg.name) {
|
|
701
790
|
nameless?.push(dir)
|
|
702
791
|
// A nameless manifest can't be addressed, filtered, or made affected —
|
|
@@ -730,7 +819,7 @@ export async function discoverProjects(
|
|
|
730
819
|
}
|
|
731
820
|
if (group.length === 1) {
|
|
732
821
|
const { dir, pkg, configPath } = group[0]!
|
|
733
|
-
projects.push({ name, dir, packageJson: pkg, configPath })
|
|
822
|
+
projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
|
|
734
823
|
continue
|
|
735
824
|
}
|
|
736
825
|
// The root's own manifest is '' relative to itself, and the refusal read
|
|
@@ -809,6 +898,19 @@ export async function namedProject(
|
|
|
809
898
|
if (typeof named?.name !== 'string' || named.name === '' || typeof named.dir !== 'string') {
|
|
810
899
|
throw new UserError(`${where} ${JSON.stringify(named)}: expected { dir: string, name: string }`)
|
|
811
900
|
}
|
|
901
|
+
// The rules `parsePackageJson` holds a manifest's name to: a plugin's name
|
|
902
|
+
// for a directory with no `name` of its own skipped them, and `a#b` planned
|
|
903
|
+
// under `--all` while no run spec or dependsOn could reach it.
|
|
904
|
+
if (named.name.trim() !== named.name) {
|
|
905
|
+
throw new UserError(
|
|
906
|
+
`${where} ${JSON.stringify(named.name)}: the name has surrounding whitespace`,
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
if (named.name.includes('#')) {
|
|
910
|
+
throw new UserError(
|
|
911
|
+
`${where} "${named.name}": the name holds "#" — vx addresses a task as <name>#<task>`,
|
|
912
|
+
)
|
|
913
|
+
}
|
|
812
914
|
const dir = path.resolve(workspace.root, named.dir)
|
|
813
915
|
const rel = relPosix(workspace.root, dir)
|
|
814
916
|
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
@@ -848,5 +950,10 @@ export async function namedProject(
|
|
|
848
950
|
`${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
|
|
849
951
|
)
|
|
850
952
|
}
|
|
851
|
-
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
|
|
852
959
|
}
|