@vzn/vx 0.0.0 → 0.0.12

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 +332 -1
  3. package/launcher.mjs +84 -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 +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.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 +470 -0
  90. package/src/workspace/workspace.ts +176 -0
package/src/cli/run.ts ADDED
@@ -0,0 +1,661 @@
1
+ import readline from 'node:readline/promises'
2
+ import path from 'node:path'
3
+ import {
4
+ affectedProjects,
5
+ applyFilters,
6
+ buildPackageGraph,
7
+ defaultAffectedBase,
8
+ findWorkspaceRoot,
9
+ listProjects,
10
+ loadProjectConfig,
11
+ loadWorkspace,
12
+ loadWorkspaceConfig,
13
+ parseFilter,
14
+ resolveCacheDir,
15
+ type ProjectMeta,
16
+ } from '../workspace/index.js'
17
+ import {
18
+ planRun,
19
+ formatRunReportMarkdown,
20
+ optionsToRequest,
21
+ resolveBackend as resolvePluginBackend,
22
+ type OutcomeView,
23
+ type RunOptions,
24
+ type RunResult,
25
+ type VxPlugin,
26
+ } from '../orchestrator/index.js'
27
+ import type { ContinueMode } from '../graph/index.js'
28
+ import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
29
+ import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
30
+ import { localBackend } from './backend.js'
31
+
32
+ export interface RunArgs {
33
+ /** Failure propagation (`--continue[=never|deps-ok|always]`). */
34
+ continueMode?: ContinueMode
35
+ /**
36
+ * Positional task names. Each entry is either a bare task (`'build'`)
37
+ * — applied across the resolved project scope — or anchored as
38
+ * `'pkg#task'` to target a specific project directly. Multiple
39
+ * entries run in one orchestrator invocation with a shared graph.
40
+ */
41
+ tasks: string[]
42
+ filters: string[]
43
+ all: boolean
44
+ /**
45
+ * `'all'` → skip every `dependsOn` edge (run just the requested task).
46
+ * `[]` → no exclusion (default).
47
+ * `[...names]` → drop only these specific dep names.
48
+ */
49
+ excludeDependencies: 'all' | string[]
50
+ concurrency: number | undefined
51
+ /**
52
+ * Resolved 4-axis cache policy after applying `--cache` / `--no-cache`
53
+ * / `--force` in precedence order. Defaults to all-on.
54
+ */
55
+ cache: CachePolicy
56
+ /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
57
+ cacheDir: string | undefined
58
+ frozen: boolean
59
+ /**
60
+ * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
61
+ * without their own `exec.retries`. Undefined when not passed.
62
+ */
63
+ retries: number | undefined
64
+ /**
65
+ * `--timeout <ms>` / `--timeout=<ms>`: run-level default task timeout for
66
+ * tasks without their own `exec.timeout`. Sits above the `VX_TASK_TIMEOUT`
67
+ * env and workspace `timeout` defaults. Undefined when not passed.
68
+ */
69
+ timeout: number | undefined
70
+ /**
71
+ * `--verify[=determinism|inputs|all]`: cache-correctness verification.
72
+ * Undefined when not passed. `determinism` re-runs and content-compares
73
+ * outputs; `inputs` sandboxes with the declared-input baseline and flags
74
+ * undeclared reads. `allow` (from `--verify-allow=<pkg#task>,…`) exempts
75
+ * tasks from failing the run on a divergence.
76
+ */
77
+ verify: { determinism: boolean; inputs: boolean } | undefined
78
+ verifyAllow: string[]
79
+ outputLogs?: 'full' | 'errors-only' | 'none'
80
+ forwardArgs: string[]
81
+ verbosity: number
82
+ dry: 'text' | 'json' | undefined
83
+ graph: string | undefined
84
+ summarize: string | undefined
85
+ profile: string | undefined
86
+ /**
87
+ * `--affected[=<base>]`. When undefined the flag wasn't passed.
88
+ * Empty string means "use the default base" (resolved later via
89
+ * `defaultAffectedBase`). Any other string is an explicit git ref.
90
+ */
91
+ affected: string | undefined
92
+ /** `--tag k=v` pairs (repeatable). Empty key is a parse error. */
93
+ tags: Record<string, string>
94
+ /** `--report[=markdown]`. Undefined when the flag wasn't passed. */
95
+ report: 'markdown' | undefined
96
+ error?: string
97
+ }
98
+
99
+ export function parseRunArgs(args: readonly string[]): RunArgs {
100
+ const out: RunArgs = {
101
+ tasks: [],
102
+ filters: [],
103
+ all: false,
104
+ excludeDependencies: [],
105
+ concurrency: undefined,
106
+ cache: { ...FULL_CACHE_POLICY },
107
+ cacheDir: undefined,
108
+ frozen: false,
109
+ retries: undefined,
110
+ timeout: undefined,
111
+ verify: undefined,
112
+ verifyAllow: [],
113
+ forwardArgs: [],
114
+ verbosity: 0,
115
+ dry: undefined,
116
+ graph: undefined,
117
+ summarize: undefined,
118
+ profile: undefined,
119
+ affected: undefined,
120
+ tags: {},
121
+ report: undefined,
122
+ }
123
+
124
+ const sepIdx = args.indexOf('--')
125
+ const before = sepIdx === -1 ? args : args.slice(0, sepIdx)
126
+ out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
127
+
128
+ // Cache policy is resolved AFTER the loop in precedence order:
129
+ // start all-true → apply each `--cache` spec → `--no-cache` forces
130
+ // all false → `--force` forces both reads false. So `--no-cache`
131
+ // beats `--force`, and `--cache` is the base both may override.
132
+ let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
133
+ let cacheSpecError: string | undefined
134
+ let noCacheFlag = false
135
+ let forceFlag = false
136
+
137
+ for (let i = 0; i < before.length; i++) {
138
+ const a = before[i]
139
+ if (a === '--filter') {
140
+ const v = before[++i]
141
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
142
+ out.filters.push(v)
143
+ } else if (a === '--concurrency') {
144
+ const v = before[++i]
145
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
146
+ const n = Number(v)
147
+ if (!Number.isFinite(n) || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
148
+ out.concurrency = Math.floor(n)
149
+ } else if (a === '--all') {
150
+ out.all = true
151
+ } else if (a === '--excludeDependencies') {
152
+ out.excludeDependencies = 'all'
153
+ } else if (a?.startsWith('--excludeDependencies=')) {
154
+ const raw = a.slice('--excludeDependencies='.length)
155
+ out.excludeDependencies = raw
156
+ .split(',')
157
+ .map((s) => s.trim())
158
+ .filter((s) => s.length > 0)
159
+ } else if (a === '--frozen') {
160
+ out.frozen = true
161
+ } else if (a === '--retry' || a?.startsWith('--retry=')) {
162
+ const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
163
+ if (v === undefined) return { ...out, error: `--retry requires a value` }
164
+ const n = Number(v)
165
+ if (v === '' || !Number.isInteger(n) || n < 0) {
166
+ return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
167
+ }
168
+ out.retries = n
169
+ } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
170
+ const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
171
+ if (v === undefined) return { ...out, error: `--timeout requires a value` }
172
+ const n = Number(v)
173
+ if (v === '' || !Number.isInteger(n) || n <= 0) {
174
+ return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
175
+ }
176
+ out.timeout = n
177
+ } else if (a === '--verify' || a?.startsWith('--verify=')) {
178
+ const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
179
+ if (what === 'determinism') {
180
+ out.verify = { determinism: true, inputs: false }
181
+ } else if (what === 'inputs') {
182
+ out.verify = { determinism: false, inputs: true }
183
+ } else if (what === 'all') {
184
+ out.verify = { determinism: true, inputs: true }
185
+ } else {
186
+ return {
187
+ ...out,
188
+ error: `--verify must be determinism | inputs | all (or bare --verify), got: ${what}`,
189
+ }
190
+ }
191
+ } else if (a?.startsWith('--verify-allow=')) {
192
+ out.verifyAllow = a
193
+ .slice('--verify-allow='.length)
194
+ .split(',')
195
+ .map((s) => s.trim())
196
+ .filter((s) => s.length > 0)
197
+ } else if (a === '--output-logs') {
198
+ const v = before[++i]
199
+ if (v !== 'full' && v !== 'errors-only' && v !== 'none') {
200
+ return { ...out, error: `--output-logs must be full, errors-only, or none` }
201
+ }
202
+ out.outputLogs = v
203
+ } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
204
+ const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
205
+ if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
206
+ out.cacheDir = v
207
+ } else if (a === '--cache') {
208
+ const v = before[++i]
209
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
210
+ try {
211
+ cachePolicy = parseCachePolicy(v, cachePolicy)
212
+ } catch (err) {
213
+ cacheSpecError = err instanceof Error ? err.message : String(err)
214
+ }
215
+ } else if (a?.startsWith('--cache=')) {
216
+ const v = a.slice('--cache='.length)
217
+ try {
218
+ cachePolicy = parseCachePolicy(v, cachePolicy)
219
+ } catch (err) {
220
+ cacheSpecError = err instanceof Error ? err.message : String(err)
221
+ }
222
+ } else if (a === '--no-cache') {
223
+ noCacheFlag = true
224
+ } else if (a === '--force') {
225
+ forceFlag = true
226
+ } else if (a === '--continue') {
227
+ // Bare --continue = 'always' (the Turbo convention).
228
+ out.continueMode = 'always'
229
+ } else if (a?.startsWith('--continue=')) {
230
+ const v = a.slice('--continue='.length)
231
+ if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
232
+ return { ...out, error: `--continue must be never, deps-ok, or always` }
233
+ }
234
+ out.continueMode = v
235
+ } else if (a === '--verbosity') {
236
+ const v = before[++i]
237
+ if (v === undefined) return { ...out, error: `${a} requires a value` }
238
+ const n = Number(v)
239
+ if (!Number.isInteger(n) || n < 0) return { ...out, error: `invalid verbosity: ${v}` }
240
+ out.verbosity = n
241
+ } else if (a === '--dry') {
242
+ out.dry = 'text'
243
+ } else if (a?.startsWith('--dry=')) {
244
+ const fmt = a.slice('--dry='.length)
245
+ if (fmt !== 'text' && fmt !== 'json') {
246
+ return { ...out, error: `invalid --dry value: ${fmt}` }
247
+ }
248
+ out.dry = fmt
249
+ } else if (a === '--graph') {
250
+ out.graph = ''
251
+ } else if (a?.startsWith('--graph=')) {
252
+ out.graph = a.slice('--graph='.length)
253
+ } else if (a === '--summarize') {
254
+ out.summarize = ''
255
+ } else if (a?.startsWith('--summarize=')) {
256
+ out.summarize = a.slice('--summarize='.length)
257
+ } else if (a === '--profile') {
258
+ out.profile = 'profile.json'
259
+ } else if (a?.startsWith('--profile=')) {
260
+ out.profile = a.slice('--profile='.length)
261
+ } else if (a === '--affected') {
262
+ out.affected = ''
263
+ } else if (a?.startsWith('--affected=')) {
264
+ out.affected = a.slice('--affected='.length)
265
+ } else if (a === '--tag' || a?.startsWith('--tag=')) {
266
+ const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
267
+ if (raw === undefined) return { ...out, error: `${a} requires a value` }
268
+ // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
269
+ const eq = raw.indexOf('=')
270
+ if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
271
+ out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
272
+ } else if (a === '--report') {
273
+ out.report = 'markdown'
274
+ } else if (a?.startsWith('--report=')) {
275
+ const fmt = a.slice('--report='.length)
276
+ if (fmt !== 'markdown') {
277
+ return { ...out, error: `invalid --report value: ${fmt} (only markdown)` }
278
+ }
279
+ out.report = fmt
280
+ } else if (a !== undefined && a.startsWith('-')) {
281
+ return { ...out, error: `unknown flag: ${a}` }
282
+ } else if (a !== undefined) {
283
+ out.tasks.push(a)
284
+ }
285
+ }
286
+
287
+ if (cacheSpecError !== undefined) {
288
+ return { ...out, error: cacheSpecError }
289
+ }
290
+ // Precedence: --no-cache (all off) beats --force (reads off, writes
291
+ // kept), both layered on top of any --cache spec.
292
+ if (noCacheFlag) {
293
+ cachePolicy = { localRead: false, localWrite: false, remoteRead: false, remoteWrite: false }
294
+ }
295
+ if (forceFlag) {
296
+ cachePolicy = { ...cachePolicy, localRead: false, remoteRead: false }
297
+ }
298
+ out.cache = cachePolicy
299
+
300
+ if (out.dry !== undefined && out.graph !== undefined) {
301
+ return { ...out, error: '--dry and --graph are mutually exclusive' }
302
+ }
303
+ if (out.dry !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
304
+ return { ...out, error: '--dry skips execution; --summarize / --profile need a real run' }
305
+ }
306
+ if (out.graph !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
307
+ return { ...out, error: '--graph skips execution; --summarize / --profile need a real run' }
308
+ }
309
+ return out
310
+ }
311
+
312
+ /**
313
+ * Classify the run's intent from its selection flags. BROAD iff the
314
+ * invocation used `--all` / `--filter` / `--affected` — the user asked
315
+ * about a swath of the workspace and wants news, not output. Everything
316
+ * else is FOCUSED — the user is running "their" task and wants to see
317
+ * it. Owner decision: cwd and task count are irrelevant ("when just run
318
+ * no --all etc then single. cwd does not matter").
319
+ */
320
+ export function detectFlow(
321
+ parsed: Pick<RunArgs, 'all' | 'filters' | 'affected'>,
322
+ ): 'focused' | 'broad' {
323
+ return parsed.all || parsed.filters.length > 0 || parsed.affected !== undefined
324
+ ? 'broad'
325
+ : 'focused'
326
+ }
327
+
328
+ /**
329
+ * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
330
+ * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
331
+ * honor the same selection semantics (cwd / `--all` / `--filter` /
332
+ * `--affected` / anchored positionals).
333
+ *
334
+ * Returns either the resolved options or an error message string. The
335
+ * caller is responsible for prefixing the message with its subcommand
336
+ * name (`vx run: <msg>` / `vx watch: <msg>`) and exiting non-zero.
337
+ *
338
+ * Assumes the caller has already populated `parsed.tasks` (e.g. via
339
+ * the interactive picker for `vx run`).
340
+ */
341
+ export async function resolveRunOptions(
342
+ parsed: RunArgs,
343
+ cwd: string,
344
+ tasks: readonly string[],
345
+ ): Promise<RunOptions | { error: string }> {
346
+ for (const t of tasks) {
347
+ const idx = t.indexOf('#')
348
+ if (idx >= 0) {
349
+ const project = t.slice(0, idx)
350
+ const task = t.slice(idx + 1)
351
+ if (!project || !task) {
352
+ return { error: `invalid pkg#task: ${t}` }
353
+ }
354
+ }
355
+ }
356
+
357
+ // `--affected[=<base>]` is sugar for `--filter '[<base>]'`. Merging
358
+ // it into the filter list means the same code path handles plain
359
+ // filter use, --affected alone, and the combo.
360
+ const filterStrings = [...parsed.filters]
361
+ if (parsed.affected !== undefined) {
362
+ const root = await findWorkspaceRoot(cwd)
363
+ const base = parsed.affected === '' ? await defaultAffectedBase(root) : parsed.affected
364
+ filterStrings.push(`[${base}]`)
365
+ }
366
+
367
+ // Project scope applies to bare task names only. Anchored entries
368
+ // (pkg#task) resolve directly to their own project regardless.
369
+ const bareTasks = tasks.filter((t) => !t.includes('#'))
370
+ let projects: string[] | undefined
371
+ if (bareTasks.length === 0) {
372
+ projects = undefined
373
+ } else if (filterStrings.length > 0) {
374
+ const resolved = await resolveFilters(cwd, filterStrings)
375
+ if (resolved.error) return { error: resolved.error }
376
+ projects = resolved.names
377
+ } else if (parsed.all) {
378
+ projects = undefined
379
+ } else {
380
+ const cwdProject = await findCwdProject(cwd)
381
+ if (!cwdProject) {
382
+ return {
383
+ error:
384
+ 'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
385
+ }
386
+ }
387
+ projects = [cwdProject]
388
+ }
389
+
390
+ const opts: RunOptions = {
391
+ cwd,
392
+ tasks: [...tasks],
393
+ cache: parsed.cache,
394
+ flow: detectFlow(parsed),
395
+ ...(parsed.frozen ? { frozen: true } : {}),
396
+ ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
397
+ ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
398
+ forwardArgs: parsed.forwardArgs,
399
+ }
400
+ if (parsed.excludeDependencies === 'all') {
401
+ opts.excludeDependencies = 'all'
402
+ } else if (parsed.excludeDependencies.length > 0) {
403
+ opts.excludeDependencies = parsed.excludeDependencies
404
+ }
405
+ if (projects !== undefined) opts.projects = projects
406
+ if (parsed.retries !== undefined) opts.retries = parsed.retries
407
+ if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
408
+ if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
409
+ if (parsed.verify !== undefined) {
410
+ opts.verify = {
411
+ determinism: parsed.verify.determinism,
412
+ inputs: parsed.verify.inputs,
413
+ allow: new Set(parsed.verifyAllow),
414
+ }
415
+ }
416
+ if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
417
+ if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
418
+ if (parsed.profile !== undefined) opts.profile = parsed.profile
419
+ if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
420
+
421
+ return opts
422
+ }
423
+
424
+ export async function runCmd(args: readonly string[]): Promise<number> {
425
+ const parsed = parseRunArgs(args)
426
+ if (parsed.error) {
427
+ process.stderr.write(`vx run: ${parsed.error}\n`)
428
+ return 1
429
+ }
430
+
431
+ const cwd = process.cwd()
432
+ let tasks = [...parsed.tasks]
433
+
434
+ // No positionals → interactive picker (TTY only). Yields a single
435
+ // anchored pkg#task; the rest of the pipeline treats it like any
436
+ // explicit anchored positional.
437
+ if (tasks.length === 0) {
438
+ if (!process.stdin.isTTY) {
439
+ process.stderr.write(`vx run: missing task name (stdin is not a TTY)\n`)
440
+ return 1
441
+ }
442
+ const picked = await pickTask(cwd)
443
+ if (!picked) return 1
444
+ tasks = [`${picked.project}#${picked.task}`]
445
+ }
446
+
447
+ const resolved = await resolveRunOptions(parsed, cwd, tasks)
448
+ if ('error' in resolved) {
449
+ process.stderr.write(`vx run: ${resolved.error}\n`)
450
+ return 1
451
+ }
452
+ const opts = resolved
453
+ // The raw invocation, recorded on the `invocations` row so dashboards
454
+ // show what was actually run. `args` is everything after `run`.
455
+ opts.command = ['vx', 'run', ...args].join(' ')
456
+
457
+ // Planning paths short-circuit execution. Both build the full task
458
+ // graph + probe the cache; the difference is just the formatter.
459
+ if (parsed.dry !== undefined || parsed.graph !== undefined) {
460
+ const plan = await planRun(opts)
461
+ if (plan.tasks.length === 0) {
462
+ process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
463
+ return 1
464
+ }
465
+ if (parsed.graph !== undefined) {
466
+ const out = formatGraphDot(plan)
467
+ if (parsed.graph === '') {
468
+ process.stdout.write(out)
469
+ } else {
470
+ await Bun.write(parsed.graph, out)
471
+ }
472
+ } else if (parsed.dry === 'json') {
473
+ process.stdout.write(formatPlanJson(plan))
474
+ } else {
475
+ process.stdout.write(formatPlanText(plan))
476
+ }
477
+ return 0
478
+ }
479
+
480
+ // Resolve where this run executes. A plugin's `backend` capability wins
481
+ // first (e.g. a backend plugin routes to a local-or-hosted service);
482
+ // otherwise core's default is pure in-process. Core names no service —
483
+ // delegation is entirely a plugin concern.
484
+ const request = optionsToRequest(opts)
485
+ const root = await findWorkspaceRoot(cwd)
486
+ const workspaceConfig = await loadWorkspaceConfig(root)
487
+ const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
488
+ const backend = await resolvePluginBackend(
489
+ plugins,
490
+ {
491
+ workspaceRoot: root,
492
+ cacheDir: resolveCacheDir(root, workspaceConfig),
493
+ warn: (m) => process.stderr.write(`${m}\n`),
494
+ request,
495
+ },
496
+ () => Promise.resolve(localBackend()),
497
+ )
498
+ const result = await backend.run(request)
499
+ if (parsed.verbosity > 0) printSummary(result)
500
+ // Report generation is post-run, gated on the flag — zero cost when
501
+ // absent. Goes to stdout (NOT the status logger) so it stays
502
+ // machine-clean for `vx run ci --report=markdown >> $GITHUB_STEP_SUMMARY`.
503
+ if (parsed.report === 'markdown') {
504
+ process.stdout.write(formatRunReportMarkdown(result))
505
+ }
506
+ return result.ok ? 0 : 1
507
+ }
508
+
509
+ async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
510
+ const root = await findWorkspaceRoot(cwd)
511
+ const ws = await loadWorkspace(root)
512
+ return await listProjects(ws)
513
+ }
514
+
515
+ async function findCwdProject(cwd: string): Promise<string | null> {
516
+ const projects = await loadWorkspaceProjects(cwd)
517
+ const abs = path.resolve(cwd)
518
+ let best: ProjectMeta | null = null
519
+ for (const p of projects) {
520
+ if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
521
+ if (!best || p.dir.length > best.dir.length) best = p
522
+ }
523
+ }
524
+ return best?.name ?? null
525
+ }
526
+
527
+ async function resolveFilters(
528
+ cwd: string,
529
+ raw: string[],
530
+ ): Promise<{ names?: string[]; error?: string }> {
531
+ const root = await findWorkspaceRoot(cwd)
532
+ const projects = await loadWorkspaceProjects(cwd)
533
+ const graph = buildPackageGraph(projects)
534
+ const parsed = raw.map((r) => parseFilter(r, root))
535
+
536
+ // Resolve every `[<since>]` filter against git before the pure
537
+ // applyFilters pass runs. One spawn per distinct ref — usually
538
+ // there's only one anyway.
539
+ const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
540
+ for (const f of parsed) {
541
+ if (f.gitSince === undefined) continue
542
+ try {
543
+ const names = await affectedProjects({ workspaceRoot: root, since: f.gitSince, projects })
544
+ affectedByFilter.set(f, names)
545
+ } catch (err) {
546
+ const msg = err instanceof Error ? err.message : String(err)
547
+ return { error: msg }
548
+ }
549
+ }
550
+
551
+ const selected = applyFilters({ filters: parsed, projects, graph, affectedByFilter })
552
+ if (selected.size === 0) {
553
+ return { error: `no projects matched filter(s): ${raw.join(', ')}` }
554
+ }
555
+ return { names: [...selected].sort() }
556
+ }
557
+
558
+ interface PickedTask {
559
+ project: string
560
+ task: string
561
+ description?: string
562
+ }
563
+
564
+ export async function pickTask(
565
+ cwd: string,
566
+ io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
567
+ ): Promise<PickedTask | null> {
568
+ const projects = await loadWorkspaceProjects(cwd)
569
+ const entries: PickedTask[] = []
570
+ for (const meta of projects) {
571
+ if (!meta.configPath) continue
572
+ const config = await loadProjectConfig(meta.configPath)
573
+ const taskNames = Object.keys(config.tasks ?? {}).sort()
574
+ for (const t of taskNames) {
575
+ const desc = config.tasks?.[t]?.description
576
+ entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
577
+ }
578
+ }
579
+ if (entries.length === 0) {
580
+ process.stderr.write(`vx run: no tasks declared in any project\n`)
581
+ return null
582
+ }
583
+ const out = io.output ?? process.stdout
584
+ const numW = String(entries.length).length
585
+ const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
586
+ out.write('Tasks:\n')
587
+ entries.forEach((e, i) => {
588
+ const n = String(i + 1).padStart(numW, ' ')
589
+ const id = `${e.project}#${e.task}`.padEnd(idW)
590
+ const desc = e.description ? ` ${e.description}` : ''
591
+ out.write(` ${n}. ${id}${desc}\n`)
592
+ })
593
+ const rl = readline.createInterface({
594
+ input: io.input ?? process.stdin,
595
+ output: io.output ?? process.stdout,
596
+ })
597
+ try {
598
+ const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
599
+ const n = Number(answer)
600
+ if (!Number.isInteger(n) || n < 1 || n > entries.length) {
601
+ process.stderr.write(`vx run: invalid selection: ${answer}\n`)
602
+ return null
603
+ }
604
+ return entries[n - 1] ?? null
605
+ } finally {
606
+ rl.close()
607
+ }
608
+ }
609
+
610
+ function printSummary(summary: RunResult): void {
611
+ const rows = summary.outcomes.map((o) => formatRow(o))
612
+ if (rows.length === 0) return
613
+ const widths = {
614
+ task: Math.max(4, ...rows.map((r) => r.task.length)),
615
+ status: Math.max(6, ...rows.map((r) => r.status.length)),
616
+ duration: Math.max(8, ...rows.map((r) => r.duration.length)),
617
+ }
618
+ const header =
619
+ 'TASK'.padEnd(widths.task) +
620
+ ' ' +
621
+ 'STATUS'.padEnd(widths.status) +
622
+ ' ' +
623
+ 'DURATION'.padStart(widths.duration)
624
+ process.stdout.write(`\n${header}\n`)
625
+ process.stdout.write('-'.repeat(header.length) + '\n')
626
+ for (const r of rows) {
627
+ process.stdout.write(
628
+ r.task.padEnd(widths.task) +
629
+ ' ' +
630
+ r.status.padEnd(widths.status) +
631
+ ' ' +
632
+ r.duration.padStart(widths.duration) +
633
+ '\n',
634
+ )
635
+ }
636
+ }
637
+
638
+ function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
639
+ // Same outcome vocabulary as the framed blocks + summary:
640
+ // executed / restored-local / restored-remote / up-to-date /
641
+ // failed / skipped.
642
+ const status =
643
+ o.status === 'cache-hit'
644
+ ? o.restored === false
645
+ ? 'up-to-date'
646
+ : 'restored-local'
647
+ : o.status === 'cache-hit-remote'
648
+ ? o.restored === false
649
+ ? 'up-to-date'
650
+ : 'restored-remote'
651
+ : o.status === 'success'
652
+ ? 'executed'
653
+ : o.status === 'failed'
654
+ ? `failed (exit ${o.exitCode})`
655
+ : o.status
656
+ return {
657
+ task: o.taskId,
658
+ status,
659
+ duration: `${o.durationMs}ms`,
660
+ }
661
+ }