@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { Dirent } from 'node:fs'
|
|
1
2
|
import { readdir, stat } from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
|
-
import { relPosix, UserError } from '../util/index.js'
|
|
5
|
+
import { BUN_GLOB_WILDCARDS, relPosix, UserError, normalizeBunGlob } from '../util/index.js'
|
|
6
|
+
import { type LoadReads, readOnce } from './load-reads.js'
|
|
5
7
|
|
|
6
8
|
export interface PackageJson {
|
|
7
9
|
name: string
|
|
@@ -37,7 +39,14 @@ export interface ProjectEntry {
|
|
|
37
39
|
config: ProjectConfig
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
const
|
|
42
|
+
export const PROJECT_CONFIG_FILENAMES = [
|
|
43
|
+
'vx.config.ts',
|
|
44
|
+
'vx.config.mts',
|
|
45
|
+
'vx.config.js',
|
|
46
|
+
'vx.config.mjs',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const decoder = new TextDecoder()
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* Walk up from `start` to find the workspace root. A directory is a root
|
|
@@ -55,15 +64,20 @@ const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.c
|
|
|
55
64
|
* When no candidate claims `start` — a standalone package, or a subdirectory
|
|
56
65
|
* of a single-project repo — the nearest candidate wins (the root itself IS
|
|
57
66
|
* the project). Throws a `UserError` when there is no candidate before `/`.
|
|
67
|
+
* A load that goes on to `loadWorkspace` passes its `reads`, so the
|
|
68
|
+
* root's manifest is read once for both.
|
|
58
69
|
*/
|
|
59
|
-
export async function findWorkspaceRoot(
|
|
70
|
+
export async function findWorkspaceRoot(
|
|
71
|
+
start: string,
|
|
72
|
+
reads: LoadReads = new Map(),
|
|
73
|
+
): Promise<string> {
|
|
60
74
|
let dir = path.resolve(start)
|
|
61
75
|
const below: string[] = []
|
|
62
76
|
let nearest: string | null = null
|
|
63
77
|
while (true) {
|
|
64
78
|
let globs: string[] | null
|
|
65
79
|
try {
|
|
66
|
-
globs = await readPackageGlobs(dir)
|
|
80
|
+
globs = await readPackageGlobs(dir, reads)
|
|
67
81
|
} catch {
|
|
68
82
|
// An unparseable manifest is still a root SIGNAL (the pre-existing
|
|
69
83
|
// behaviour probed only for existence); it just can't claim members.
|
|
@@ -73,10 +87,19 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
|
|
|
73
87
|
if (globs !== null) {
|
|
74
88
|
nearest ??= dir
|
|
75
89
|
if (claimsMember(dir, below, globs)) return dir
|
|
90
|
+
// pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
|
|
91
|
+
// not. Walking past it, `apps/inner` resolved to the outer workspace
|
|
92
|
+
// while `apps/inner/pkgs/x` resolved to the inner one: two roots and
|
|
93
|
+
// two caches for one tree (item 990). Already read: `reads` holds it.
|
|
94
|
+
if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) return dir
|
|
76
95
|
}
|
|
77
96
|
const parent = path.dirname(dir)
|
|
78
97
|
if (parent === dir) break
|
|
79
|
-
|
|
98
|
+
// Only a directory holding a manifest can be a member: a glob that
|
|
99
|
+
// matched `packages/tools`, which has none, claimed the standalone
|
|
100
|
+
// package below it, and `packages/tools/standalone` ran as a stranger
|
|
101
|
+
// in a workspace that does not list it (item 989).
|
|
102
|
+
if (globs !== null) below.push(dir)
|
|
80
103
|
dir = parent
|
|
81
104
|
}
|
|
82
105
|
if (nearest !== null) return nearest
|
|
@@ -89,8 +112,9 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
|
|
|
89
112
|
/** True when one of `below` (dirs under `root`, toward `start`) is a member. */
|
|
90
113
|
function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
|
|
91
114
|
if (below.length === 0 || globs.length === 0) return false
|
|
92
|
-
const
|
|
93
|
-
|
|
115
|
+
const { positive, negative } = splitPackageGlobs(globs)
|
|
116
|
+
const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
|
|
117
|
+
for (const pattern of positive) {
|
|
94
118
|
const normalized = pattern.replace(/\/+$/, '')
|
|
95
119
|
// `.` means the root itself is the project — never a directory below it.
|
|
96
120
|
if (normalized === '' || normalized === '.') continue
|
|
@@ -100,21 +124,75 @@ function claimsMember(root: string, below: readonly string[], globs: readonly st
|
|
|
100
124
|
return false
|
|
101
125
|
}
|
|
102
126
|
|
|
127
|
+
/**
|
|
128
|
+
* pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
|
|
129
|
+
* list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
|
|
130
|
+
* so `!packages/fixtures/package.json` matched every manifest in the tree
|
|
131
|
+
* and every directory holding one became a project — the excluded package
|
|
132
|
+
* ran under `--all`, and a fixture repeating a name killed the run with
|
|
133
|
+
* "Duplicate package name". Negations subtract from what the positive
|
|
134
|
+
* globs found; a literal one excludes its tree.
|
|
135
|
+
*/
|
|
136
|
+
function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
|
|
137
|
+
const positive: string[] = []
|
|
138
|
+
const negative: string[] = []
|
|
139
|
+
// `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
|
|
140
|
+
// (2026-09-10): the same spellings the input globs normalize.
|
|
141
|
+
for (const raw of globs) {
|
|
142
|
+
// A member glob names directories, so a trailing slash says nothing:
|
|
143
|
+
// npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
|
|
144
|
+
// gives it a task glob's meaning, `/**`, and `packages/*/` found every
|
|
145
|
+
// example and fixture package at any depth below the members (item 985).
|
|
146
|
+
const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
|
|
147
|
+
if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
|
|
148
|
+
else positive.push(g)
|
|
149
|
+
}
|
|
150
|
+
return { positive, negative }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function excludedBy(rel: string, negative: readonly string[]): boolean {
|
|
154
|
+
for (const neg of negative) {
|
|
155
|
+
if (neg.length === 0) continue
|
|
156
|
+
if (rel === neg || rel.startsWith(`${neg}/`)) return true
|
|
157
|
+
if (!BUN_GLOB_WILDCARDS.test(neg)) continue
|
|
158
|
+
if (new Bun.Glob(neg).match(rel)) return true
|
|
159
|
+
// pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
|
|
160
|
+
// `!**/test/**` excludes `packages/test` itself; matched against the
|
|
161
|
+
// directory, `**/test/**` needs something below `test` and the package
|
|
162
|
+
// stayed a member (item 986).
|
|
163
|
+
if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
103
168
|
/**
|
|
104
169
|
* Package globs declared by `dir`, or `null` when `dir` is not a root
|
|
105
170
|
* candidate. A bare `package.json` (no `workspaces`) is single-project mode:
|
|
106
171
|
* the root itself is the only project, hence `['.']`.
|
|
107
172
|
*/
|
|
108
|
-
async function readPackageGlobs(dir: string): Promise<string[] | null> {
|
|
173
|
+
async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
|
|
109
174
|
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
175
|
+
const yaml = await readOnce(reads, yamlPath)
|
|
176
|
+
let yamlWithoutPackages = false
|
|
177
|
+
if (yaml !== null) {
|
|
178
|
+
const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
|
|
179
|
+
if (parsed !== null && parsed !== undefined) {
|
|
180
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
181
|
+
throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
|
|
182
|
+
}
|
|
183
|
+
const packages = (parsed as { packages?: unknown }).packages
|
|
184
|
+
if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
|
|
185
|
+
}
|
|
186
|
+
// No `packages:`: pnpm 10 keeps its settings and catalogs in this file
|
|
187
|
+
// for a single-package repo too. The root's package.json decides, as it
|
|
188
|
+
// would without the file; read as an empty list, the root found zero
|
|
189
|
+
// projects and every verb ran nothing and exited 0 (item 984).
|
|
190
|
+
yamlWithoutPackages = true
|
|
114
191
|
}
|
|
115
192
|
const pkgPath = path.join(dir, 'package.json')
|
|
116
|
-
|
|
117
|
-
|
|
193
|
+
const pkgBytes = await readOnce(reads, pkgPath)
|
|
194
|
+
if (pkgBytes === null) return yamlWithoutPackages ? [] : null
|
|
195
|
+
const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
|
|
118
196
|
const ws = pkg.workspaces as unknown
|
|
119
197
|
if (ws === undefined || ws === null) return ['.']
|
|
120
198
|
if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
|
|
@@ -127,6 +205,49 @@ async function readPackageGlobs(dir: string): Promise<string[] | null> {
|
|
|
127
205
|
return assertGlobList(ws, pkgPath, 'workspaces')
|
|
128
206
|
}
|
|
129
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Package manifests the globs never reach, in single-project mode only: a
|
|
210
|
+
* root `package.json` with no `workspaces` field makes the root the one
|
|
211
|
+
* project, and a `packages/app/package.json` full of scripts beside it is
|
|
212
|
+
* then invisible — `vx init` said "no package.json scripts" and `vx run`
|
|
213
|
+
* said "run vx init" (2026-09-16). One shallow scan, two levels, on the
|
|
214
|
+
* failure path only; `node_modules` and dot directories are skipped.
|
|
215
|
+
*/
|
|
216
|
+
export async function unreachedPackages(workspace: Workspace): Promise<string[]> {
|
|
217
|
+
if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
|
|
218
|
+
const found: string[] = []
|
|
219
|
+
// Two scans: Bun.Glob does not expand a brace whose alternatives hold a
|
|
220
|
+
// slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
|
|
221
|
+
for (const pattern of ['*/package.json', '*/*/package.json']) {
|
|
222
|
+
for await (const rel of new Bun.Glob(pattern).scan({
|
|
223
|
+
cwd: workspace.root,
|
|
224
|
+
onlyFiles: true,
|
|
225
|
+
dot: false,
|
|
226
|
+
})) {
|
|
227
|
+
const posix = rel.split(path.sep).join('/')
|
|
228
|
+
if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
|
|
229
|
+
found.push(posix.slice(0, -'/package.json'.length))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return found.sort()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The line for `unreachedPackages`' finding: the cause, the packages, the glob to add. */
|
|
236
|
+
export function unreachedHint(unreached: readonly string[]): string {
|
|
237
|
+
const n = unreached.length
|
|
238
|
+
const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
|
|
239
|
+
const globs = [
|
|
240
|
+
...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
|
|
241
|
+
]
|
|
242
|
+
.map((g) => `"${g}"`)
|
|
243
|
+
.join(', ')
|
|
244
|
+
return (
|
|
245
|
+
`package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), so the root is the only project ` +
|
|
246
|
+
`and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}. ` +
|
|
247
|
+
`Add \`"workspaces": [${globs}]\` to package.json and re-run.`
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
130
251
|
/**
|
|
131
252
|
* Parse a workspace manifest, naming the FILE on failure. The raw parser
|
|
132
253
|
* errors (`Failed to parse JSON`) carry no path, so in a 1000-package
|
|
@@ -141,6 +262,25 @@ function parseManifest(text: string, file: string, parse: (t: string) => unknown
|
|
|
141
262
|
}
|
|
142
263
|
}
|
|
143
264
|
|
|
265
|
+
/**
|
|
266
|
+
* A `package.json` as discovery reads it: a JSON object whose `name`, when
|
|
267
|
+
* present, is a string with no surrounding whitespace (npm refuses one).
|
|
268
|
+
* Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
|
|
269
|
+
* `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
|
|
270
|
+
* projects (item 988).
|
|
271
|
+
*/
|
|
272
|
+
function parsePackageJson(text: string, file: string): PackageJson {
|
|
273
|
+
const pkg = parseManifest(text, file, JSON.parse)
|
|
274
|
+
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
|
|
275
|
+
throw new UserError(`${file}: must be a JSON object`)
|
|
276
|
+
}
|
|
277
|
+
const name = (pkg as { name?: unknown }).name
|
|
278
|
+
if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
|
|
279
|
+
throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
|
|
280
|
+
}
|
|
281
|
+
return pkg as PackageJson
|
|
282
|
+
}
|
|
283
|
+
|
|
144
284
|
/**
|
|
145
285
|
* A glob list is user input from a manifest: `packages: "packages/*"`
|
|
146
286
|
* (a bare string instead of a list) is an easy YAML slip, and without
|
|
@@ -150,9 +290,46 @@ function assertGlobList(value: unknown, file: string, field: string): string[] {
|
|
|
150
290
|
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
|
|
151
291
|
throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
|
|
152
292
|
}
|
|
293
|
+
for (const pattern of value as string[]) {
|
|
294
|
+
if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
|
|
295
|
+
}
|
|
153
296
|
return value as string[]
|
|
154
297
|
}
|
|
155
298
|
|
|
299
|
+
/**
|
|
300
|
+
* An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm (minimatch)
|
|
301
|
+
* and yarn (micromatch) read `packages/!(x)` as "every directory in
|
|
302
|
+
* packages/ but x"; `Bun.Glob` has no extglob, and its scan read the
|
|
303
|
+
* segment as a wildcard, so the excluded package became a project
|
|
304
|
+
* (turborepo#3766) while its match read the same text literally. vx cannot
|
|
305
|
+
* honour the pattern, so it refuses it rather than read it either way.
|
|
306
|
+
*/
|
|
307
|
+
const EXTGLOB = /[!@+*?]\(/
|
|
308
|
+
|
|
309
|
+
function extglobRefusal(pattern: string, file: string, field: string): UserError {
|
|
310
|
+
const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
|
|
311
|
+
// The common shape, a whole segment excluding plain names, has an exact
|
|
312
|
+
// rewrite in the `!` entries every package manager takes.
|
|
313
|
+
const segments = pattern.split('/')
|
|
314
|
+
const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
|
|
315
|
+
const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
|
|
316
|
+
const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
|
|
317
|
+
const rest = segments.filter((_s, i) => i !== at).join('/')
|
|
318
|
+
if (at === -1 || !plain || EXTGLOB.test(rest)) {
|
|
319
|
+
return new UserError(
|
|
320
|
+
`${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
const before = segments.slice(0, at)
|
|
324
|
+
const rewrite = [
|
|
325
|
+
[...before, '*', ...segments.slice(at + 1)].join('/'),
|
|
326
|
+
...names.map((n) => `!${[...before, n].join('/')}`),
|
|
327
|
+
]
|
|
328
|
+
return new UserError(
|
|
329
|
+
`${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
156
333
|
/**
|
|
157
334
|
* Resolve the cache directory for a workspace. Respects the user's
|
|
158
335
|
* `defineWorkspace({ cacheDir })` override (relative to the workspace
|
|
@@ -173,8 +350,8 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
|
|
|
173
350
|
* If a `package.json` exists with no `workspaces` field, the root
|
|
174
351
|
* itself is treated as a single-project workspace.
|
|
175
352
|
*/
|
|
176
|
-
export async function loadWorkspace(root: string): Promise<Workspace> {
|
|
177
|
-
const packageGlobs = await readPackageGlobs(root)
|
|
353
|
+
export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
|
|
354
|
+
const packageGlobs = await readPackageGlobs(root, reads)
|
|
178
355
|
if (packageGlobs === null) {
|
|
179
356
|
// Should be unreachable: findWorkspaceRoot only returns dirs that
|
|
180
357
|
// pass at least one of the two existence checks.
|
|
@@ -188,12 +365,30 @@ export async function loadWorkspace(root: string): Promise<Workspace> {
|
|
|
188
365
|
// path.
|
|
189
366
|
const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
|
|
190
367
|
|
|
368
|
+
/**
|
|
369
|
+
* The directory each `<dir>/*` package glob names — where a member comes
|
|
370
|
+
* and goes as one directory entry, so `vx watch` can hear a package added
|
|
371
|
+
* or removed without walking anything. A glob of another shape (`apps/**`)
|
|
372
|
+
* has no such directory and contributes none.
|
|
373
|
+
*/
|
|
374
|
+
export function memberBaseDirs(workspace: Workspace): string[] {
|
|
375
|
+
const bases = new Set<string>()
|
|
376
|
+
for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
|
|
377
|
+
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
378
|
+
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
379
|
+
bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return [...bases]
|
|
383
|
+
}
|
|
384
|
+
|
|
191
385
|
/**
|
|
192
386
|
* The directories a workspace glob names. For the `<dir>/*` shape this is
|
|
193
387
|
* one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
|
|
194
388
|
* the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
|
|
195
|
-
* symlinked member is followed when it points at a directory,
|
|
196
|
-
*
|
|
389
|
+
* symlinked member is followed when it points at a directory, and so is
|
|
390
|
+
* it by the scan any other shape takes, unless the glob holds `**` (item
|
|
391
|
+
* 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
|
|
197
392
|
*/
|
|
198
393
|
async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
199
394
|
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
@@ -224,7 +419,12 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
|
224
419
|
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
225
420
|
const glob = new Bun.Glob(globPattern)
|
|
226
421
|
const dirs: string[] = []
|
|
227
|
-
|
|
422
|
+
// A linked member is a member, as the readdir path above has it:
|
|
423
|
+
// `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
|
|
424
|
+
// `pack*/*` did not (item 987). Followed only where the depth is bounded:
|
|
425
|
+
// under `**` the scan would walk every pnpm `node_modules` link.
|
|
426
|
+
const followSymlinks = !normalized.includes('**')
|
|
427
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
|
|
228
428
|
// Skip nested node_modules — workspace package globs shouldn't
|
|
229
429
|
// ever reach into them, but a pathological pattern like `**`
|
|
230
430
|
// would. Avoid splitting the path on the hot loop.
|
|
@@ -236,32 +436,67 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
|
236
436
|
}
|
|
237
437
|
|
|
238
438
|
/**
|
|
239
|
-
* The project's config file, by `
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
439
|
+
* The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
|
|
440
|
+
*
|
|
441
|
+
* Two shapes, one per platform, each the measured winner there across
|
|
442
|
+
* 1,000 projects with the manifest read in flight alongside:
|
|
443
|
+
* - macOS stats the candidates in order: a readdir each cost 9.6 ms
|
|
444
|
+
* against 6.3 ms of stats even with the config at the LAST name
|
|
445
|
+
* (2026-09-03), and a `.ts` config — the first name — pays one stat.
|
|
446
|
+
* - Linux lists the directory once: a readdir each is 12 ms against
|
|
447
|
+
* 43 ms of stats at the last name and ties the one-stat case
|
|
448
|
+
* (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
|
|
449
|
+
* thread-pool round trip apiece).
|
|
450
|
+
* Both answer identically: a directory or dangling link under a config
|
|
451
|
+
* name is skipped for the next name, a symlink to a file counts.
|
|
244
452
|
*/
|
|
245
453
|
async function findConfigFile(dir: string): Promise<string | null> {
|
|
246
|
-
|
|
247
|
-
|
|
454
|
+
if (process.platform === 'linux') {
|
|
455
|
+
let entries: Dirent[]
|
|
248
456
|
try {
|
|
249
|
-
|
|
457
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
250
458
|
} catch {
|
|
251
|
-
|
|
459
|
+
return null
|
|
252
460
|
}
|
|
461
|
+
const byName = new Map(entries.map((e) => [e.name, e]))
|
|
462
|
+
for (const name of PROJECT_CONFIG_FILENAMES) {
|
|
463
|
+
const entry = byName.get(name)
|
|
464
|
+
if (entry === undefined) continue
|
|
465
|
+
const candidate = path.join(dir, name)
|
|
466
|
+
if (entry.isFile()) return candidate
|
|
467
|
+
if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
|
|
468
|
+
}
|
|
469
|
+
return null
|
|
470
|
+
}
|
|
471
|
+
for (const name of PROJECT_CONFIG_FILENAMES) {
|
|
472
|
+
const candidate = path.join(dir, name)
|
|
473
|
+
if (await isFile(candidate)) return candidate
|
|
253
474
|
}
|
|
254
475
|
return null
|
|
255
476
|
}
|
|
256
477
|
|
|
478
|
+
async function isFile(p: string): Promise<boolean> {
|
|
479
|
+
try {
|
|
480
|
+
return (await stat(p)).isFile()
|
|
481
|
+
} catch {
|
|
482
|
+
return false
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
257
486
|
export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
|
|
258
487
|
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
259
488
|
// well; serializing them just stretches the discovery phase by N×.
|
|
489
|
+
const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
|
|
260
490
|
const perPattern = await Promise.all(
|
|
261
|
-
|
|
491
|
+
positive.map((pattern) => memberDirs(workspace.root, pattern)),
|
|
262
492
|
)
|
|
263
493
|
const matches = new Set<string>()
|
|
264
|
-
for (const arr of perPattern)
|
|
494
|
+
for (const arr of perPattern) {
|
|
495
|
+
for (const m of arr) {
|
|
496
|
+
if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
|
|
497
|
+
matches.add(m)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
265
500
|
|
|
266
501
|
// Per-project discovery: the manifest read answers "is there a
|
|
267
502
|
// package.json" (an ENOENT is the "not a member" answer, at the cost of
|
|
@@ -279,7 +514,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
279
514
|
.catch(() => null),
|
|
280
515
|
])
|
|
281
516
|
if (text === null) return null
|
|
282
|
-
const pkg =
|
|
517
|
+
const pkg = parsePackageJson(text, pkgJsonPath)
|
|
283
518
|
return { dir, pkg, configPath }
|
|
284
519
|
}),
|
|
285
520
|
)
|
package/src/cache/cas-backend.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// CASBackend — pluggable content-addressed storage beneath the cache.
|
|
2
|
-
//
|
|
3
|
-
// Today's Cache + LayeredCache both bundle "where do the bytes live"
|
|
4
|
-
// with "how do we look up entries metadata + key derivation."
|
|
5
|
-
// CASBackend separates the former so a future R2/S3 backend
|
|
6
|
-
// or REAPI CAS bridge can drop in without touching the
|
|
7
|
-
// orchestrator or the SQL entries index.
|
|
8
|
-
//
|
|
9
|
-
// Two reference implementations in this file:
|
|
10
|
-
// - MemoryCASBackend — testing + in-memory builds. Holds raw bytes.
|
|
11
|
-
// - FsCASBackend — writes bytes to a directory; mirrors what
|
|
12
|
-
// Cache.save does today. Bun-native using Bun.file
|
|
13
|
-
// for the read path so big artifacts stream
|
|
14
|
-
// without slurping into memory.
|
|
15
|
-
//
|
|
16
|
-
// Cache.ts has NOT yet been rewired to use a CASBackend — that's a
|
|
17
|
-
// follow-up (Phase 1b). This file ships the abstraction + the two
|
|
18
|
-
// reference impls so downstream work (R2, otel-bridge, distributed-ci)
|
|
19
|
-
// can rely on the type. Byte-identical behaviour today.
|
|
20
|
-
|
|
21
|
-
import { mkdir, rename, rm } from 'node:fs/promises'
|
|
22
|
-
import path from 'node:path'
|
|
23
|
-
import type { Digest } from './digest.js'
|
|
24
|
-
|
|
25
|
-
/** Per-process counter for put() temp names; uniqueness only. */
|
|
26
|
-
let casTmpSeq = 0
|
|
27
|
-
|
|
28
|
-
export interface CASBackend {
|
|
29
|
-
/** Write bytes under `digest`. Idempotent — putting the same digest twice is a no-op. */
|
|
30
|
-
put(digest: Digest, bytes: Uint8Array): Promise<void>
|
|
31
|
-
/** Read bytes under `digest`, or null if absent. */
|
|
32
|
-
get(digest: Digest): Promise<Uint8Array | null>
|
|
33
|
-
/** Cheap existence probe — no bytes round-tripped if possible. */
|
|
34
|
-
has(digest: Digest): Promise<boolean>
|
|
35
|
-
/** Drop one entry (eviction). No-op if absent. */
|
|
36
|
-
remove(digest: Digest): Promise<void>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** In-memory backend, useful for tests and ephemeral runs. */
|
|
40
|
-
export class MemoryCASBackend implements CASBackend {
|
|
41
|
-
private readonly store = new Map<string, Uint8Array>()
|
|
42
|
-
|
|
43
|
-
async put(digest: Digest, bytes: Uint8Array): Promise<void> {
|
|
44
|
-
if (bytes.byteLength !== digest.sizeBytes) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`MemoryCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
this.store.set(digest.hash, bytes)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async get(digest: Digest): Promise<Uint8Array | null> {
|
|
53
|
-
return this.store.get(digest.hash) ?? null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async has(digest: Digest): Promise<boolean> {
|
|
57
|
-
return this.store.has(digest.hash)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async remove(digest: Digest): Promise<void> {
|
|
61
|
-
this.store.delete(digest.hash)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Test-only: how many entries are held. */
|
|
65
|
-
size(): number {
|
|
66
|
-
return this.store.size
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Filesystem-backed backend writing `<rootDir>/<hash>.tar.zst` files. */
|
|
71
|
-
export class FsCASBackend implements CASBackend {
|
|
72
|
-
constructor(private readonly rootDir: string) {}
|
|
73
|
-
|
|
74
|
-
private pathFor(digest: Digest): string {
|
|
75
|
-
return path.join(this.rootDir, `${digest.hash}.tar.zst`)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async put(digest: Digest, bytes: Uint8Array): Promise<void> {
|
|
79
|
-
if (bytes.byteLength !== digest.sizeBytes) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`FsCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
// Temp + rename, the same shape `Cache.writeArtifactAndIndex` and the
|
|
85
|
-
// archive extractor use: two writers of the SAME content-addressed
|
|
86
|
-
// blob are expected (that is what content-addressing invites), and a
|
|
87
|
-
// plain write lets a reader observe a half-written file under a name
|
|
88
|
-
// that promises complete bytes. rename(2) is atomic, so a concurrent
|
|
89
|
-
// reader sees either the old file or the whole new one.
|
|
90
|
-
const final = this.pathFor(digest)
|
|
91
|
-
const tmp = `${final}.tmp-${process.pid.toString(36)}-${(casTmpSeq++).toString(36)}`
|
|
92
|
-
await mkdir(this.rootDir, { recursive: true })
|
|
93
|
-
try {
|
|
94
|
-
await Bun.write(tmp, bytes)
|
|
95
|
-
await rename(tmp, final)
|
|
96
|
-
} catch (err) {
|
|
97
|
-
await rm(tmp, { force: true })
|
|
98
|
-
throw err
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async get(digest: Digest): Promise<Uint8Array | null> {
|
|
103
|
-
const file = Bun.file(this.pathFor(digest))
|
|
104
|
-
if (!(await file.exists())) return null
|
|
105
|
-
return new Uint8Array(await file.arrayBuffer())
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async has(digest: Digest): Promise<boolean> {
|
|
109
|
-
return Bun.file(this.pathFor(digest)).exists()
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async remove(digest: Digest): Promise<void> {
|
|
113
|
-
await rm(this.pathFor(digest), { force: true })
|
|
114
|
-
}
|
|
115
|
-
}
|
package/src/cache/digest.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// Digest — the explicit content-address used by every cache layer.
|
|
2
|
-
//
|
|
3
|
-
// Lifted as a first-class type (architecture-review-2026-06.md §4.3)
|
|
4
|
-
// so storage backends become pluggable beneath CacheLayer: local FS,
|
|
5
|
-
// R2, S3, or a future REAPI CAS bridge all speak (hash, sizeBytes).
|
|
6
|
-
//
|
|
7
|
-
// Two practical wins from making this explicit:
|
|
8
|
-
// 1. sizeBytes is the truncation check at transport boundaries (HTTP
|
|
9
|
-
// Content-Length, R2 ETag-by-size). Free correctness, free corrupt-
|
|
10
|
-
// artifact-early-fail.
|
|
11
|
-
// 2. CASBackend.has(digest) can answer with just the metadata, no
|
|
12
|
-
// bytes round-trip — what existence probes want.
|
|
13
|
-
//
|
|
14
|
-
// Why sizeBytes is `number` and not `bigint`: Bun.file().size returns
|
|
15
|
-
// number; JS Number safely represents integers to 2^53-1 (~9 PB); no
|
|
16
|
-
// real cache artifact gets there. Bigint would create the same
|
|
17
|
-
// JSON.stringify-throws nuisance we already navigated for ns timestamps.
|
|
18
|
-
|
|
19
|
-
export interface Digest {
|
|
20
|
-
readonly hash: string
|
|
21
|
-
readonly sizeBytes: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function makeDigest(hash: string, sizeBytes: number): Digest {
|
|
25
|
-
if (!/^[0-9a-f]+$/i.test(hash)) {
|
|
26
|
-
throw new Error(`invalid digest hash: ${JSON.stringify(hash)}`)
|
|
27
|
-
}
|
|
28
|
-
if (!Number.isInteger(sizeBytes) || sizeBytes < 0) {
|
|
29
|
-
throw new Error(`invalid digest sizeBytes: ${sizeBytes}`)
|
|
30
|
-
}
|
|
31
|
-
return { hash, sizeBytes }
|
|
32
|
-
}
|
package/src/cli/migrate-emit.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Shared TS-literal emission helper for the migrate mappers. Lives in its own
|
|
2
|
-
// leaf module so both migrate.ts (the config emitter) and migrate-turbo.ts (the
|
|
3
|
-
// preset renderer) can escape strings identically without a dependency cycle
|
|
4
|
-
// (migrate.ts already imports the turbo/nx mappers, so a back-import of a value
|
|
5
|
-
// from migrate.ts would close a runtime cycle).
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Escape an arbitrary string into a single-quoted TS literal. Escapes
|
|
9
|
-
* backslash + quote AND raw newlines/CR — a value with an embedded newline
|
|
10
|
-
* (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
|
|
11
|
-
* otherwise splice into a single-quoted literal as an unterminated / malformed
|
|
12
|
-
* string that fails to load (generated files must round-trip through the
|
|
13
|
-
* loader).
|
|
14
|
-
*/
|
|
15
|
-
export function quote(s: string): string {
|
|
16
|
-
return `'${s
|
|
17
|
-
.replaceAll('\\', '\\\\')
|
|
18
|
-
.replaceAll("'", "\\'")
|
|
19
|
-
.replaceAll('\n', '\\n')
|
|
20
|
-
.replaceAll('\r', '\\r')}'`
|
|
21
|
-
}
|