@vzn/vx 0.0.0 → 0.0.14
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 +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
3
|
+
import { parseSize, UserError, xxh3hex } from '../util/index.js'
|
|
4
|
+
|
|
5
|
+
const WORKSPACE_CONFIG_FILENAMES = [
|
|
6
|
+
'vx.workspace.ts',
|
|
7
|
+
'vx.workspace.mts',
|
|
8
|
+
'vx.workspace.js',
|
|
9
|
+
'vx.workspace.mjs',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
// Bun has native TS / ESM execution — no transpiler dep needed. We fold
|
|
13
|
+
// a short content hash into the import URL as a cache-bust key so that:
|
|
14
|
+
// same content → same URL → Bun's module cache hits (fast)
|
|
15
|
+
// changed content → new URL → fresh re-evaluation (correct)
|
|
16
|
+
// mtime would be cheaper but Bun's stat().mtimeNs is currently undefined
|
|
17
|
+
// on Linux/macOS, and ms-resolution mtime misses rapid edits in tests.
|
|
18
|
+
// Hashing a typical <10 KB config file is ~50µs — not measurable next
|
|
19
|
+
// to the import() evaluation itself.
|
|
20
|
+
async function loadDefaultExport(
|
|
21
|
+
configPath: string,
|
|
22
|
+
kind: string,
|
|
23
|
+
fresh = false,
|
|
24
|
+
): Promise<unknown> {
|
|
25
|
+
const bytes = await Bun.file(configPath).bytes()
|
|
26
|
+
// `fresh` opts out of module-cache reuse entirely: `vx lock` and
|
|
27
|
+
// `vx lock --check` must observe the CURRENT environment, and the
|
|
28
|
+
// content-hash bust would replay an evaluation made under earlier
|
|
29
|
+
// env values when the file bytes are unchanged in this process.
|
|
30
|
+
const bust = fresh ? `${xxh3hex(bytes)}-${Bun.randomUUIDv7()}` : xxh3hex(bytes)
|
|
31
|
+
const ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
|
|
32
|
+
const mod = ns?.default
|
|
33
|
+
if (!mod || typeof mod !== 'object') {
|
|
34
|
+
throw new UserError(`${kind} config at ${configPath} did not export a default object`)
|
|
35
|
+
}
|
|
36
|
+
return mod
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function loadProjectConfig(
|
|
40
|
+
configPath: string,
|
|
41
|
+
opts?: { fresh?: boolean },
|
|
42
|
+
): Promise<ProjectConfig> {
|
|
43
|
+
const mod = (await loadDefaultExport(
|
|
44
|
+
configPath,
|
|
45
|
+
'Project',
|
|
46
|
+
opts?.fresh === true,
|
|
47
|
+
)) as ProjectConfig
|
|
48
|
+
validateProjectConfig(mod, configPath)
|
|
49
|
+
return mod
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
|
|
54
|
+
* root. Returns `null` if no such file exists (the common case;
|
|
55
|
+
* the schema is fully optional). Validates the shape and throws
|
|
56
|
+
* a `UserError` on malformed input.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
|
|
59
|
+
let configPath: string | null = null
|
|
60
|
+
for (const candidate of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
|
|
61
|
+
if (await Bun.file(candidate).exists()) {
|
|
62
|
+
configPath = candidate
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!configPath) return null
|
|
67
|
+
const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
|
|
68
|
+
validateWorkspace(mod, configPath)
|
|
69
|
+
return mod
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
|
|
73
|
+
if (config.concurrency !== undefined) {
|
|
74
|
+
if (
|
|
75
|
+
typeof config.concurrency !== 'number' ||
|
|
76
|
+
!Number.isFinite(config.concurrency) ||
|
|
77
|
+
config.concurrency < 1 ||
|
|
78
|
+
!Number.isInteger(config.concurrency)
|
|
79
|
+
) {
|
|
80
|
+
throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
|
|
84
|
+
throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
|
|
85
|
+
}
|
|
86
|
+
if (config.timeout !== undefined) {
|
|
87
|
+
if (
|
|
88
|
+
typeof config.timeout !== 'number' ||
|
|
89
|
+
!Number.isInteger(config.timeout) ||
|
|
90
|
+
config.timeout <= 0
|
|
91
|
+
) {
|
|
92
|
+
throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (config.plugins !== undefined) {
|
|
96
|
+
if (!Array.isArray(config.plugins)) {
|
|
97
|
+
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
98
|
+
}
|
|
99
|
+
for (const [i, p] of config.plugins.entries()) {
|
|
100
|
+
if (p === null || typeof p !== 'object') {
|
|
101
|
+
throw new UserError(`${configPath}: \`plugins[${i}]\` must be an object`)
|
|
102
|
+
}
|
|
103
|
+
const plug = p as {
|
|
104
|
+
name?: unknown
|
|
105
|
+
setup?: unknown
|
|
106
|
+
backend?: unknown
|
|
107
|
+
cache?: unknown
|
|
108
|
+
telemetry?: unknown
|
|
109
|
+
eventSink?: unknown
|
|
110
|
+
teardown?: unknown
|
|
111
|
+
}
|
|
112
|
+
if (typeof plug.name !== 'string' || plug.name.length === 0) {
|
|
113
|
+
throw new UserError(`${configPath}: \`plugins[${i}].name\` must be a non-empty string`)
|
|
114
|
+
}
|
|
115
|
+
for (const cap of [
|
|
116
|
+
'setup',
|
|
117
|
+
'backend',
|
|
118
|
+
'cache',
|
|
119
|
+
'telemetry',
|
|
120
|
+
'eventSink',
|
|
121
|
+
'teardown',
|
|
122
|
+
] as const) {
|
|
123
|
+
if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
|
|
124
|
+
throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// A plugin must contribute at least one capability or lifecycle hook
|
|
128
|
+
// — an empty `{ name }` object is a no-op authoring mistake.
|
|
129
|
+
if (
|
|
130
|
+
plug.setup === undefined &&
|
|
131
|
+
plug.backend === undefined &&
|
|
132
|
+
plug.cache === undefined &&
|
|
133
|
+
plug.telemetry === undefined &&
|
|
134
|
+
plug.eventSink === undefined &&
|
|
135
|
+
plug.teardown === undefined
|
|
136
|
+
) {
|
|
137
|
+
throw new UserError(
|
|
138
|
+
`${configPath}: \`plugins[${i}]\` must contribute at least one of setup/backend/cache/telemetry/eventSink`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (config.predictive !== undefined && typeof config.predictive !== 'boolean') {
|
|
144
|
+
throw new UserError(`${configPath}: \`predictive\` must be a boolean`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Runtime validation for the user-authored config. TypeScript checks
|
|
150
|
+
* shape at edit-time, but `vx run` may load configs that were never
|
|
151
|
+
* typechecked (plain .js, or TS with errors ignored). Catch the worst
|
|
152
|
+
* shape problems early with a clear message rather than letting them
|
|
153
|
+
* crash deeper in the orchestrator. Also applied to configs loaded
|
|
154
|
+
* back from `vx-lock.json` (a hand-editable file — same boundary).
|
|
155
|
+
*/
|
|
156
|
+
export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
|
|
157
|
+
const tasks = config.tasks
|
|
158
|
+
if (tasks === undefined) return
|
|
159
|
+
if (typeof tasks !== 'object' || tasks === null) {
|
|
160
|
+
throw new UserError(`${configPath}: \`tasks\` must be an object`)
|
|
161
|
+
}
|
|
162
|
+
for (const [name, task] of Object.entries(tasks)) {
|
|
163
|
+
const where = `${configPath}: tasks.${name}`
|
|
164
|
+
if (!task || typeof task !== 'object') {
|
|
165
|
+
throw new UserError(`${where} must be an object`)
|
|
166
|
+
}
|
|
167
|
+
const exec = (task as { exec?: unknown }).exec
|
|
168
|
+
const dependsOn = (task as { dependsOn?: unknown }).dependsOn
|
|
169
|
+
const cache = (task as { cache?: unknown }).cache
|
|
170
|
+
if (exec !== undefined) {
|
|
171
|
+
if (typeof exec !== 'object' || exec === null) {
|
|
172
|
+
throw new UserError(`${where}.exec must be an object with a \`command\` string`)
|
|
173
|
+
}
|
|
174
|
+
const command = (exec as { command?: unknown }).command
|
|
175
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
176
|
+
throw new UserError(`${where}.exec.command must be a non-empty string`)
|
|
177
|
+
}
|
|
178
|
+
const timeout = (exec as { timeout?: unknown }).timeout
|
|
179
|
+
if (timeout !== undefined) {
|
|
180
|
+
if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
|
|
181
|
+
throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const retries = (exec as { retries?: unknown }).retries
|
|
185
|
+
if (retries !== undefined) {
|
|
186
|
+
if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
|
|
187
|
+
throw new UserError(`${where}.exec.retries must be a non-negative integer`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const resources = (exec as { resources?: unknown }).resources
|
|
191
|
+
if (resources !== undefined) {
|
|
192
|
+
validateResources(resources, `${where}.exec.resources`)
|
|
193
|
+
}
|
|
194
|
+
const persistent = (exec as { persistent?: unknown }).persistent
|
|
195
|
+
if (persistent !== undefined) {
|
|
196
|
+
if (typeof persistent !== 'object' || persistent === null) {
|
|
197
|
+
throw new UserError(`${where}.exec.persistent must be an object (or omitted)`)
|
|
198
|
+
}
|
|
199
|
+
const readyWhen = (persistent as { readyWhen?: unknown }).readyWhen
|
|
200
|
+
if (readyWhen !== undefined && typeof readyWhen !== 'string') {
|
|
201
|
+
throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
|
|
202
|
+
}
|
|
203
|
+
if (cache !== undefined) {
|
|
204
|
+
throw new UserError(
|
|
205
|
+
`${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
|
|
206
|
+
`don't terminate, so there's no exit to cache`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (retries !== undefined) {
|
|
210
|
+
throw new UserError(
|
|
211
|
+
`${where}: \`retries\` is not allowed on a persistent task — persistent tasks ` +
|
|
212
|
+
`don't terminate, so there's no failed exit to retry`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Group task: no exec, just dependencies. Must declare something to
|
|
218
|
+
// depend on, otherwise the task is a literal no-op with nothing to
|
|
219
|
+
// chain (almost certainly a config mistake).
|
|
220
|
+
if (dependsOn === undefined) {
|
|
221
|
+
throw new UserError(
|
|
222
|
+
`${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
|
|
223
|
+
`(group tasks exist to chain dependencies)`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
if (cache !== undefined) {
|
|
227
|
+
throw new UserError(
|
|
228
|
+
`${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const description = (task as { description?: unknown }).description
|
|
233
|
+
if (description !== undefined && typeof description !== 'string') {
|
|
234
|
+
throw new UserError(`${where}.description must be a string`)
|
|
235
|
+
}
|
|
236
|
+
if (dependsOn !== undefined) {
|
|
237
|
+
if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
|
|
238
|
+
throw new UserError(
|
|
239
|
+
`${where}.dependsOn must be an array of strings ` +
|
|
240
|
+
`(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (cache !== undefined) {
|
|
245
|
+
if (typeof cache !== 'object' || cache === null) {
|
|
246
|
+
throw new UserError(`${where}.cache must be an object when present`)
|
|
247
|
+
}
|
|
248
|
+
const inputs = (cache as { inputs?: unknown }).inputs
|
|
249
|
+
const outputs = (cache as { outputs?: unknown }).outputs
|
|
250
|
+
if (!inputs || typeof inputs !== 'object') {
|
|
251
|
+
throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
|
|
252
|
+
}
|
|
253
|
+
if (!Array.isArray((inputs as { files?: unknown }).files)) {
|
|
254
|
+
throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
|
|
255
|
+
}
|
|
256
|
+
const envList = (inputs as { env?: unknown }).env
|
|
257
|
+
if (envList !== undefined) {
|
|
258
|
+
if (
|
|
259
|
+
!Array.isArray(envList) ||
|
|
260
|
+
envList.some((s) => typeof s !== 'string' || s.length === 0)
|
|
261
|
+
) {
|
|
262
|
+
throw new UserError(
|
|
263
|
+
`${where}.cache.inputs.env must be an array of non-empty env var names`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
for (const name of envList as string[]) {
|
|
267
|
+
// Reject wildcards explicitly so users don't silently miss
|
|
268
|
+
// env vars they thought they were tracking. Turbo supports
|
|
269
|
+
// `VERCEL_*` expansion; vx requires the literal names. If
|
|
270
|
+
// we add expansion later it'll be additive — until then,
|
|
271
|
+
// surface the footgun instead of returning '' for the
|
|
272
|
+
// literal env name `'VERCEL_*'`.
|
|
273
|
+
if (/[*?[\]]/.test(name)) {
|
|
274
|
+
throw new UserError(
|
|
275
|
+
`${where}.cache.inputs.env: wildcards in env names are not supported ` +
|
|
276
|
+
`(got "${name}") — list explicit env var names instead`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (!outputs || typeof outputs !== 'object') {
|
|
282
|
+
throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
|
|
283
|
+
}
|
|
284
|
+
const outFiles = (outputs as { files?: unknown }).files
|
|
285
|
+
if (!Array.isArray(outFiles)) {
|
|
286
|
+
throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
|
|
287
|
+
}
|
|
288
|
+
// Reject zero-length strings and absolute paths up front.
|
|
289
|
+
// Both reach `resolveOutputs` as undefined behavior; the error
|
|
290
|
+
// surfaces deep inside the glob resolver with no line pointing
|
|
291
|
+
// at the user's config. Fail loud at load time.
|
|
292
|
+
for (const g of outFiles as unknown[]) {
|
|
293
|
+
if (typeof g !== 'string' || g.length === 0) {
|
|
294
|
+
throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
|
|
295
|
+
}
|
|
296
|
+
if (g.startsWith('/')) {
|
|
297
|
+
throw new UserError(
|
|
298
|
+
`${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
299
|
+
`outputs must be project-relative globs`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Same for inputs.files.
|
|
304
|
+
for (const g of (inputs as { files: unknown[] }).files) {
|
|
305
|
+
if (typeof g !== 'string' || g.length === 0) {
|
|
306
|
+
throw new UserError(`${where}.cache.inputs.files must be an array of non-empty strings`)
|
|
307
|
+
}
|
|
308
|
+
if (g.startsWith('/')) {
|
|
309
|
+
throw new UserError(
|
|
310
|
+
`${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
311
|
+
`inputs must be project-relative globs`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const field of ['runtime', 'workspaceRuntime'] as const) {
|
|
316
|
+
const list = (inputs as Record<string, unknown>)[field]
|
|
317
|
+
if (list !== undefined) {
|
|
318
|
+
if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
319
|
+
throw new UserError(
|
|
320
|
+
`${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// workspaceFiles mirror the files validation: non-empty strings,
|
|
326
|
+
// never absolute (they're workspace-root-relative by definition).
|
|
327
|
+
const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
|
|
328
|
+
if (wsInputs !== undefined) {
|
|
329
|
+
validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`)
|
|
330
|
+
}
|
|
331
|
+
const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
|
|
332
|
+
if (wsOutputs !== undefined) {
|
|
333
|
+
validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const sandbox = (task as { sandbox?: unknown }).sandbox
|
|
337
|
+
if (sandbox !== undefined) validateSandbox(sandbox, where, exec !== undefined)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function validateWorkspaceGlobs(v: unknown, where: string): void {
|
|
342
|
+
if (!Array.isArray(v)) {
|
|
343
|
+
throw new UserError(`${where} must be an array of glob strings`)
|
|
344
|
+
}
|
|
345
|
+
for (const g of v as unknown[]) {
|
|
346
|
+
if (typeof g !== 'string' || g.length === 0) {
|
|
347
|
+
throw new UserError(`${where} must be an array of non-empty strings`)
|
|
348
|
+
}
|
|
349
|
+
if (g.startsWith('/') || g.startsWith('!/')) {
|
|
350
|
+
throw new UserError(
|
|
351
|
+
`${where}: absolute paths are not allowed (got "${g}") — ` +
|
|
352
|
+
`entries are workspace-root-relative globs`,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const SANDBOX_PATH_FIELDS = ['allowRead', 'allowWrite'] as const
|
|
359
|
+
const SANDBOX_BOOL_FIELDS = [
|
|
360
|
+
'allowGitConfig',
|
|
361
|
+
'allowPty',
|
|
362
|
+
'enableWeakerNestedSandbox',
|
|
363
|
+
'enableWeakerNetworkIsolation',
|
|
364
|
+
] as const
|
|
365
|
+
const SANDBOX_NETWORK_PATH_FIELDS = [
|
|
366
|
+
'allowedDomains',
|
|
367
|
+
'deniedDomains',
|
|
368
|
+
'allowUnixSockets',
|
|
369
|
+
'allowMachLookup',
|
|
370
|
+
] as const
|
|
371
|
+
const SANDBOX_NETWORK_BOOL_FIELDS = ['allowAllUnixSockets', 'allowLocalBinding'] as const
|
|
372
|
+
const SANDBOX_FIELDS = new Set<string>([
|
|
373
|
+
...SANDBOX_PATH_FIELDS,
|
|
374
|
+
...SANDBOX_BOOL_FIELDS,
|
|
375
|
+
'network',
|
|
376
|
+
'ignoreViolations',
|
|
377
|
+
])
|
|
378
|
+
const SANDBOX_NETWORK_FIELDS = new Set<string>([
|
|
379
|
+
...SANDBOX_NETWORK_PATH_FIELDS,
|
|
380
|
+
...SANDBOX_NETWORK_BOOL_FIELDS,
|
|
381
|
+
])
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validate a `sandbox: {...}` block. The field set mirrors the user-
|
|
385
|
+
* facing surface of SandboxConfig in src/config.ts; this is the only
|
|
386
|
+
* place we enforce shape at runtime (TS users get compile-time checks
|
|
387
|
+
* already).
|
|
388
|
+
*/
|
|
389
|
+
function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): void {
|
|
390
|
+
if (typeof sandbox !== 'object' || sandbox === null || Array.isArray(sandbox)) {
|
|
391
|
+
throw new UserError(
|
|
392
|
+
`${where}.sandbox must be an object (e.g. \`{}\` for the baseline, or ` +
|
|
393
|
+
`\`{ allowRead: [...], network: true }\`)`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
if (!hasExec) {
|
|
397
|
+
throw new UserError(`${where}.sandbox requires \`exec\` — a group task has nothing to wrap`)
|
|
398
|
+
}
|
|
399
|
+
for (const key of Object.keys(sandbox as object)) {
|
|
400
|
+
if (!SANDBOX_FIELDS.has(key)) {
|
|
401
|
+
throw new UserError(
|
|
402
|
+
`${where}.sandbox.${key} is not a known field. Allowed: ` +
|
|
403
|
+
`${[...SANDBOX_FIELDS].sort().join(', ')}`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const obj = sandbox as Record<string, unknown>
|
|
408
|
+
for (const field of SANDBOX_PATH_FIELDS) {
|
|
409
|
+
if (obj[field] === undefined) continue
|
|
410
|
+
assertPathArray(obj[field], `${where}.sandbox.${field}`)
|
|
411
|
+
}
|
|
412
|
+
for (const field of SANDBOX_BOOL_FIELDS) {
|
|
413
|
+
if (obj[field] !== undefined && typeof obj[field] !== 'boolean') {
|
|
414
|
+
throw new UserError(`${where}.sandbox.${field} must be a boolean`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const network = obj.network
|
|
418
|
+
if (network !== undefined && typeof network !== 'boolean') {
|
|
419
|
+
if (typeof network !== 'object' || network === null || Array.isArray(network)) {
|
|
420
|
+
throw new UserError(
|
|
421
|
+
`${where}.sandbox.network must be a boolean or an object (allowedDomains, ` +
|
|
422
|
+
`deniedDomains, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, allowMachLookup)`,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
for (const key of Object.keys(network)) {
|
|
426
|
+
if (!SANDBOX_NETWORK_FIELDS.has(key)) {
|
|
427
|
+
throw new UserError(
|
|
428
|
+
`${where}.sandbox.network.${key} is not a known field. Allowed: ` +
|
|
429
|
+
`${[...SANDBOX_NETWORK_FIELDS].sort().join(', ')}`,
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const netObj = network as Record<string, unknown>
|
|
434
|
+
for (const field of SANDBOX_NETWORK_PATH_FIELDS) {
|
|
435
|
+
if (netObj[field] === undefined) continue
|
|
436
|
+
assertStringArray(netObj[field], `${where}.sandbox.network.${field}`)
|
|
437
|
+
}
|
|
438
|
+
for (const field of SANDBOX_NETWORK_BOOL_FIELDS) {
|
|
439
|
+
if (netObj[field] !== undefined && typeof netObj[field] !== 'boolean') {
|
|
440
|
+
throw new UserError(`${where}.sandbox.network.${field} must be a boolean`)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const ignoreViolations = obj.ignoreViolations
|
|
445
|
+
if (ignoreViolations !== undefined) {
|
|
446
|
+
if (
|
|
447
|
+
typeof ignoreViolations !== 'object' ||
|
|
448
|
+
ignoreViolations === null ||
|
|
449
|
+
Array.isArray(ignoreViolations)
|
|
450
|
+
) {
|
|
451
|
+
throw new UserError(
|
|
452
|
+
`${where}.sandbox.ignoreViolations must be a record mapping command patterns to arrays of paths`,
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
for (const [k, v] of Object.entries(ignoreViolations)) {
|
|
456
|
+
assertStringArray(v, `${where}.sandbox.ignoreViolations[${JSON.stringify(k)}]`)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const RESOURCES_FIELDS = new Set(['cpus', 'memory'])
|
|
462
|
+
const PERCENT_RE = /^\d+(\.\d+)?%$/
|
|
463
|
+
|
|
464
|
+
function validateResources(resources: unknown, where: string): void {
|
|
465
|
+
if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
|
|
466
|
+
throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: '2GB' }\`)`)
|
|
467
|
+
}
|
|
468
|
+
for (const key of Object.keys(resources as object)) {
|
|
469
|
+
if (!RESOURCES_FIELDS.has(key)) {
|
|
470
|
+
throw new UserError(
|
|
471
|
+
`${where} has unknown field "${key}". Allowed: ${[...RESOURCES_FIELDS].sort().join(', ')}`,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const { cpus, memory } = resources as { cpus?: unknown; memory?: unknown }
|
|
476
|
+
if (cpus !== undefined) {
|
|
477
|
+
const ok =
|
|
478
|
+
typeof cpus === 'number'
|
|
479
|
+
? Number.isFinite(cpus) && cpus >= 0
|
|
480
|
+
: typeof cpus === 'string' && PERCENT_RE.test(cpus)
|
|
481
|
+
if (!ok) {
|
|
482
|
+
throw new UserError(`${where}.cpus must be a non-negative number or a "<n>%" string`)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (memory !== undefined) {
|
|
486
|
+
const ok =
|
|
487
|
+
typeof memory === 'number'
|
|
488
|
+
? Number.isInteger(memory) && memory >= 0
|
|
489
|
+
: typeof memory === 'string' && (PERCENT_RE.test(memory) || parseSize(memory) !== null)
|
|
490
|
+
if (!ok) {
|
|
491
|
+
throw new UserError(
|
|
492
|
+
`${where}.memory must be a non-negative integer (bytes), ` +
|
|
493
|
+
`a size string like "512MB", or a "<n>%" string`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function assertStringArray(v: unknown, where: string): void {
|
|
500
|
+
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
501
|
+
throw new UserError(`${where} must be an array of non-empty strings`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function assertPathArray(v: unknown, where: string): void {
|
|
506
|
+
assertStringArray(v, where)
|
|
507
|
+
if ((v as string[]).some((s) => /[*?[\]]/.test(s))) {
|
|
508
|
+
throw new UserError(
|
|
509
|
+
`${where} entries must be path prefixes (no globs — bwrap on Linux can't enforce them)`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
|
+
import { UserError } from '../util/index.js'
|
|
5
|
+
|
|
6
|
+
export interface PackageJson {
|
|
7
|
+
name: string
|
|
8
|
+
version?: string
|
|
9
|
+
dependencies?: Record<string, string>
|
|
10
|
+
devDependencies?: Record<string, string>
|
|
11
|
+
peerDependencies?: Record<string, string>
|
|
12
|
+
optionalDependencies?: Record<string, string>
|
|
13
|
+
/** npm / yarn / bun workspaces. May be a glob array or { packages: [...] }. */
|
|
14
|
+
workspaces?: string[] | { packages?: string[] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Workspace {
|
|
18
|
+
root: string
|
|
19
|
+
/** Glob patterns relative to root that match project directories. */
|
|
20
|
+
packageGlobs: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProjectMeta {
|
|
24
|
+
/** Canonical name from package.json. */
|
|
25
|
+
name: string
|
|
26
|
+
/** Absolute path to the project directory. */
|
|
27
|
+
dir: string
|
|
28
|
+
packageJson: PackageJson
|
|
29
|
+
/** Absolute path to vx.config.{ts,mts,js,mjs} or null. */
|
|
30
|
+
configPath: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A discovered project joined with its loaded vx config. */
|
|
34
|
+
export interface ProjectEntry {
|
|
35
|
+
name: string
|
|
36
|
+
dir: string
|
|
37
|
+
config: ProjectConfig
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walk up from `start` to find the workspace root. A directory is a
|
|
44
|
+
* workspace root if it contains either:
|
|
45
|
+
* - `pnpm-workspace.yaml`, OR
|
|
46
|
+
* - a `package.json` (with or without a `workspaces` field).
|
|
47
|
+
*
|
|
48
|
+
* The first match wins. A bare `package.json` without `workspaces`
|
|
49
|
+
* means a single-project workspace (the root itself IS the project).
|
|
50
|
+
* Throws a `UserError` if neither signal is found before `/`.
|
|
51
|
+
*/
|
|
52
|
+
export async function findWorkspaceRoot(start: string): Promise<string> {
|
|
53
|
+
let dir = path.resolve(start)
|
|
54
|
+
while (true) {
|
|
55
|
+
if (await Bun.file(path.join(dir, 'pnpm-workspace.yaml')).exists()) return dir
|
|
56
|
+
if (await Bun.file(path.join(dir, 'package.json')).exists()) return dir
|
|
57
|
+
const parent = path.dirname(dir)
|
|
58
|
+
if (parent === dir) {
|
|
59
|
+
throw new UserError(
|
|
60
|
+
`Could not find a workspace root in any parent of ${start} ` +
|
|
61
|
+
`(looked for pnpm-workspace.yaml or package.json)`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
dir = parent
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the cache directory for a workspace. Respects the user's
|
|
70
|
+
* `defineWorkspace({ cacheDir })` override (relative to the workspace
|
|
71
|
+
* root) and falls back to `.vx/cache` when no config is set.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
|
|
74
|
+
const rel = config?.cacheDir ?? path.join('.vx', 'cache')
|
|
75
|
+
return path.resolve(root, rel)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the workspace's package-glob list, supporting all common
|
|
80
|
+
* package managers:
|
|
81
|
+
* - `pnpm-workspace.yaml` (pnpm)
|
|
82
|
+
* - `package.json` `workspaces` array (npm / yarn / bun)
|
|
83
|
+
* - `package.json` `workspaces.packages` array (yarn legacy)
|
|
84
|
+
*
|
|
85
|
+
* If a `package.json` exists with no `workspaces` field, the root
|
|
86
|
+
* itself is treated as a single-project workspace.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadWorkspace(root: string): Promise<Workspace> {
|
|
89
|
+
const yamlPath = path.join(root, 'pnpm-workspace.yaml')
|
|
90
|
+
if (await Bun.file(yamlPath).exists()) {
|
|
91
|
+
const parsed = (Bun.YAML.parse(await Bun.file(yamlPath).text()) ?? {}) as {
|
|
92
|
+
packages?: string[]
|
|
93
|
+
}
|
|
94
|
+
return { root, packageGlobs: parsed.packages ?? [] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const pkgPath = path.join(root, 'package.json')
|
|
98
|
+
if (await Bun.file(pkgPath).exists()) {
|
|
99
|
+
const pkg = (await Bun.file(pkgPath).json()) as PackageJson
|
|
100
|
+
const ws = pkg.workspaces
|
|
101
|
+
if (Array.isArray(ws)) return { root, packageGlobs: ws }
|
|
102
|
+
if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
|
|
103
|
+
return { root, packageGlobs: ws.packages }
|
|
104
|
+
}
|
|
105
|
+
// Bare package.json with no `workspaces` — single-project mode.
|
|
106
|
+
// Treat the root itself as the only project.
|
|
107
|
+
return { root, packageGlobs: ['.'] }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Should be unreachable: findWorkspaceRoot only returns dirs that
|
|
111
|
+
// pass at least one of the two existence checks.
|
|
112
|
+
throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
|
|
116
|
+
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
117
|
+
// well; serializing them just stretches the discovery phase by N×.
|
|
118
|
+
const perPattern = await Promise.all(
|
|
119
|
+
workspace.packageGlobs.map(async (pattern) => {
|
|
120
|
+
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
121
|
+
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
122
|
+
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
123
|
+
const glob = new Bun.Glob(globPattern)
|
|
124
|
+
const hits: string[] = []
|
|
125
|
+
for await (const rel of glob.scan({
|
|
126
|
+
cwd: workspace.root,
|
|
127
|
+
onlyFiles: true,
|
|
128
|
+
dot: false,
|
|
129
|
+
})) {
|
|
130
|
+
// Skip nested node_modules — workspace package globs shouldn't
|
|
131
|
+
// ever reach into them, but a pathological pattern like `**`
|
|
132
|
+
// would. Avoid splitting the path on the hot loop.
|
|
133
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
134
|
+
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
135
|
+
hits.push(path.resolve(workspace.root, rel))
|
|
136
|
+
}
|
|
137
|
+
return hits
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
const matches = new Set<string>()
|
|
141
|
+
for (const arr of perPattern) for (const m of arr) matches.add(m)
|
|
142
|
+
|
|
143
|
+
// Per-project I/O (manifest read + config discovery) is independent
|
|
144
|
+
// — run it all concurrently, then a deterministic sequential pass
|
|
145
|
+
// for the duplicate-name check. One readdir per project replaces
|
|
146
|
+
// up to four exists() probes (CONFIG_FILENAMES has 4 candidates):
|
|
147
|
+
// ~5440 awaited syscalls → ~2180 concurrent ones at 1090 projects.
|
|
148
|
+
const loaded = await Promise.all(
|
|
149
|
+
[...matches].map(async (pkgJsonPath) => {
|
|
150
|
+
const dir = path.dirname(pkgJsonPath)
|
|
151
|
+
const [pkg, entries] = await Promise.all([
|
|
152
|
+
Bun.file(pkgJsonPath).json() as Promise<PackageJson>,
|
|
153
|
+
readdir(dir).catch(() => [] as string[]),
|
|
154
|
+
])
|
|
155
|
+
const names = new Set(entries)
|
|
156
|
+
const configName = CONFIG_FILENAMES.find((f) => names.has(f))
|
|
157
|
+
const configPath = configName !== undefined ? path.join(dir, configName) : null
|
|
158
|
+
return { dir, pkg, configPath }
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const projects: ProjectMeta[] = []
|
|
163
|
+
const seenName = new Map<string, string>()
|
|
164
|
+
for (const { dir, pkg, configPath } of loaded) {
|
|
165
|
+
if (!pkg.name) continue
|
|
166
|
+
const previous = seenName.get(pkg.name)
|
|
167
|
+
if (previous) {
|
|
168
|
+
throw new UserError(
|
|
169
|
+
`Duplicate package name "${pkg.name}" in workspace: ${previous} and ${dir}`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
seenName.set(pkg.name, dir)
|
|
173
|
+
projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
|
|
174
|
+
}
|
|
175
|
+
return projects.sort((a, b) => a.name.localeCompare(b.name))
|
|
176
|
+
}
|