@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. 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
+ }