@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
package/src/cli/run.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import readline from 'node:readline/promises'
2
+ import { appendFile } from 'node:fs/promises'
2
3
  import path from 'node:path'
4
+ import { documentedFlags } from './help.js'
3
5
  import {
4
6
  affectedProjects,
5
7
  applyFilters,
@@ -9,26 +11,25 @@ import {
9
11
  listProjects,
10
12
  loadProjectConfig,
11
13
  loadWorkspace,
12
- loadWorkspaceConfig,
13
14
  parseFilter,
14
- resolveCacheDir,
15
+ readLockfile,
16
+ workspaceGlobsMatch,
15
17
  type ProjectMeta,
16
18
  } from '../workspace/index.js'
17
19
  import {
18
20
  planRun,
19
21
  formatRunReportMarkdown,
20
- optionsToRequest,
21
- resolveBackend as resolvePluginBackend,
22
+ projectOutcome,
23
+ run as runOrchestrator,
22
24
  type OutcomeView,
23
25
  type RunOptions,
24
26
  type RunResult,
25
- type VxPlugin,
26
27
  } from '../orchestrator/index.js'
28
+ import type { ProjectConfig } from '../config.js'
27
29
  import type { ContinueMode } from '../graph/index.js'
28
30
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
29
- import { parseSize } from '../util/index.js'
31
+ import { MAX_TIMEOUT_MS, parseDecimalInt, parseSize, editDistance } from '../util/index.js'
30
32
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
31
- import { localBackend } from './backend.js'
32
33
 
33
34
  export interface RunArgs {
34
35
  /** Failure propagation (`--continue[=never|deps-ok|always]`). */
@@ -85,7 +86,8 @@ export interface RunArgs {
85
86
  */
86
87
  verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
87
88
  verifyAllow: string[]
88
- outputLogs?: 'full' | 'errors-only' | 'none'
89
+ outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
90
+ download?: 'all' | 'toplevel' | 'none'
89
91
  forwardArgs: string[]
90
92
  verbosity: number
91
93
  dry: 'text' | 'json' | undefined
@@ -102,6 +104,12 @@ export interface RunArgs {
102
104
  tags: Record<string, string>
103
105
  /** `--report[=markdown]`. Undefined when the flag wasn't passed. */
104
106
  report: 'markdown' | undefined
107
+ /**
108
+ * `--report-file=<path>`. Undefined when the flag wasn't passed. Writes
109
+ * the same markdown as `--report`, but to a file — because redirecting
110
+ * vx's stdout captures the whole run log, not just the report.
111
+ */
112
+ reportFile: string | undefined
105
113
  error?: string
106
114
  }
107
115
 
@@ -129,6 +137,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
129
137
  affected: undefined,
130
138
  tags: {},
131
139
  report: undefined,
140
+ reportFile: undefined,
132
141
  }
133
142
 
134
143
  const sepIdx = args.indexOf('--')
@@ -146,22 +155,34 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
146
155
 
147
156
  for (let i = 0; i < before.length; i++) {
148
157
  const a = before[i]
149
- if (a === '--filter') {
150
- const v = before[++i]
151
- if (v === undefined) return { ...out, error: `${a} requires a value` }
158
+ if (a === '--filter' || a?.startsWith('--filter=')) {
159
+ const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
160
+ if (v === undefined || v === '') return { ...out, error: `--filter requires a value` }
152
161
  out.filters.push(v)
153
- } else if (a === '--concurrency') {
154
- const v = before[++i]
155
- if (v === undefined) return { ...out, error: `${a} requires a value` }
156
- const n = Number(v)
157
- if (!Number.isFinite(n) || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
158
- out.concurrency = Math.floor(n)
162
+ } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
163
+ const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
164
+ if (v === undefined) return { ...out, error: `--concurrency requires a value` }
165
+ const n = parseDecimalInt(v)
166
+ if (n === null || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
167
+ out.concurrency = n
159
168
  } else if (a === '--all') {
160
169
  out.all = true
161
170
  } else if (a === '--excludeDependencies') {
162
171
  out.excludeDependencies = 'all'
163
172
  } else if (a?.startsWith('--excludeDependencies=')) {
164
173
  const raw = a.slice('--excludeDependencies='.length)
174
+ // An empty value is genuinely ambiguous — "drop every edge" (the
175
+ // bare flag) and "drop none" (an empty list) are equally defensible
176
+ // readings, and silently picking either does the opposite of what
177
+ // half the callers mean. Reject and name both explicit forms; the
178
+ // sibling value flags (--retry=, --timeout=, --memory=, --cache-dir=)
179
+ // reject an empty `=` value the same way.
180
+ if (raw === '') {
181
+ return {
182
+ ...out,
183
+ error: `--excludeDependencies= needs a value — pass bare --excludeDependencies to drop every dependsOn edge, or omit the flag to keep them`,
184
+ }
185
+ }
165
186
  out.excludeDependencies = raw
166
187
  .split(',')
167
188
  .map((s) => s.trim())
@@ -171,16 +192,26 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
171
192
  } else if (a === '--retry' || a?.startsWith('--retry=')) {
172
193
  const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
173
194
  if (v === undefined) return { ...out, error: `--retry requires a value` }
174
- const n = Number(v)
175
- if (v === '' || !Number.isInteger(n) || n < 0) {
195
+ const n = parseDecimalInt(v)
196
+ if (n === null) {
176
197
  return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
177
198
  }
178
199
  out.retries = n
179
200
  } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
180
201
  const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
181
202
  if (v === undefined) return { ...out, error: `--timeout requires a value` }
182
- const n = Number(v)
183
- if (v === '' || !Number.isInteger(n) || n <= 0) {
203
+ const n = parseDecimalInt(v)
204
+ if (n !== null && n > MAX_TIMEOUT_MS) {
205
+ // Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
206
+ // to 1 ms, so this would kill every task the moment it spawned.
207
+ return {
208
+ ...out,
209
+ error:
210
+ `--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
211
+ `larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
212
+ }
213
+ }
214
+ if (n === null || n <= 0) {
184
215
  return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
185
216
  }
186
217
  out.timeout = n
@@ -208,32 +239,56 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
208
239
  error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
209
240
  }
210
241
  }
211
- } else if (a?.startsWith('--verify-allow=')) {
212
- out.verifyAllow = a
213
- .slice('--verify-allow='.length)
242
+ } else if (a === '--verify-allow' || a?.startsWith('--verify-allow=')) {
243
+ const v = a === '--verify-allow' ? before[++i] : a.slice('--verify-allow='.length)
244
+ if (v === undefined) return { ...out, error: `--verify-allow requires a value` }
245
+ // A flag-shaped space-form value is always a lost flag (task ids
246
+ // never start with `-`), never a value the user meant.
247
+ if (a === '--verify-allow' && v.startsWith('-')) {
248
+ return { ...out, error: `--verify-allow requires a value, got flag: ${v}` }
249
+ }
250
+ out.verifyAllow = v
214
251
  .split(',')
215
252
  .map((s) => s.trim())
216
253
  .filter((s) => s.length > 0)
217
- } else if (a === '--output-logs') {
218
- const v = before[++i]
219
- if (v !== 'full' && v !== 'errors-only' && v !== 'none') {
220
- return { ...out, error: `--output-logs must be full, errors-only, or none` }
254
+ } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
255
+ const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
256
+ if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
257
+ return { ...out, error: `--output-logs must be full, errors-only, hash-only, or none` }
221
258
  }
222
259
  out.outputLogs = v
260
+ } else if (a === '--download' || a?.startsWith('--download=')) {
261
+ const v = a === '--download' ? before[++i] : a.slice('--download='.length)
262
+ if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
263
+ return { ...out, error: `--download must be all, toplevel, or none` }
264
+ }
265
+ out.download = v
223
266
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
224
267
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
225
268
  if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
269
+ // Unlike every other value flag, a cache dir is an arbitrary
270
+ // string — nothing about its shape rejects a swallowed flag. An
271
+ // unquoted empty shell var (`--cache-dir $EMPTY --force`) would
272
+ // otherwise create a directory literally named `--force` and drop
273
+ // the flag. A path starting with `-` needs the `=` form.
274
+ if (a === '--cache-dir' && v.startsWith('-')) {
275
+ return { ...out, error: `--cache-dir requires a path, got flag: ${v}` }
276
+ }
226
277
  out.cacheDir = v
227
- } else if (a === '--cache') {
228
- const v = before[++i]
229
- if (v === undefined) return { ...out, error: `${a} requires a value` }
230
- try {
231
- cachePolicy = parseCachePolicy(v, cachePolicy)
232
- } catch (err) {
233
- cacheSpecError = err instanceof Error ? err.message : String(err)
278
+ } else if (a === '--cache' || a?.startsWith('--cache=')) {
279
+ const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
280
+ if (v === undefined) return { ...out, error: `--cache requires a value` }
281
+ // A spec with no segment at all applies NOTHING, so `--cache=`
282
+ // (an empty shell var, or someone reaching for "no cache") left
283
+ // all four axes ON — the opposite of the intent. Reject it; an
284
+ // empty flag list for a NAMED layer (`local:`) stays valid and
285
+ // means that layer off.
286
+ if (!v.split(',').some((seg) => seg.trim().length > 0)) {
287
+ return {
288
+ ...out,
289
+ error: `--cache needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
290
+ }
234
291
  }
235
- } else if (a?.startsWith('--cache=')) {
236
- const v = a.slice('--cache='.length)
237
292
  try {
238
293
  cachePolicy = parseCachePolicy(v, cachePolicy)
239
294
  } catch (err) {
@@ -252,11 +307,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
252
307
  return { ...out, error: `--continue must be never, deps-ok, or always` }
253
308
  }
254
309
  out.continueMode = v
255
- } else if (a === '--verbosity') {
256
- const v = before[++i]
257
- if (v === undefined) return { ...out, error: `${a} requires a value` }
258
- const n = Number(v)
259
- if (!Number.isInteger(n) || n < 0) return { ...out, error: `invalid verbosity: ${v}` }
310
+ } else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
311
+ const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
312
+ if (v === undefined) return { ...out, error: `--verbosity requires a value` }
313
+ const n = parseDecimalInt(v)
314
+ if (n === null) return { ...out, error: `invalid verbosity: ${v}` }
260
315
  out.verbosity = n
261
316
  } else if (a === '--dry') {
262
317
  out.dry = 'text'
@@ -277,7 +332,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
277
332
  } else if (a === '--profile') {
278
333
  out.profile = 'profile.json'
279
334
  } else if (a?.startsWith('--profile=')) {
280
- out.profile = a.slice('--profile='.length)
335
+ // An optional-value flag: an empty `=` means "no value given", so it
336
+ // takes the same documented default as the bare form. Resolving ''
337
+ // against cwd would name the cwd DIRECTORY and die with EISDIR after
338
+ // the whole run — and `--summarize=` already degrades to its default.
339
+ out.profile = a.slice('--profile='.length) || 'profile.json'
281
340
  } else if (a === '--affected') {
282
341
  out.affected = ''
283
342
  } else if (a?.startsWith('--affected=')) {
@@ -289,6 +348,16 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
289
348
  const eq = raw.indexOf('=')
290
349
  if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
291
350
  out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
351
+ } else if (a === '--report-file' || a?.startsWith('--report-file=')) {
352
+ const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
353
+ if (v === undefined || v === '') return { ...out, error: `--report-file requires a path` }
354
+ // Same reasoning as --cache-dir: a path is an arbitrary string, so
355
+ // nothing about its shape rejects a swallowed flag. A path starting
356
+ // with `-` needs the `=` form.
357
+ if (a === '--report-file' && v.startsWith('-')) {
358
+ return { ...out, error: `--report-file requires a path, got flag: ${v}` }
359
+ }
360
+ out.reportFile = v
292
361
  } else if (a === '--report') {
293
362
  out.report = 'markdown'
294
363
  } else if (a?.startsWith('--report=')) {
@@ -298,7 +367,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
298
367
  }
299
368
  out.report = fmt
300
369
  } else if (a !== undefined && a.startsWith('-')) {
301
- return { ...out, error: `unknown flag: ${a}` }
370
+ return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}` }
302
371
  } else if (a !== undefined) {
303
372
  out.tasks.push(a)
304
373
  }
@@ -351,9 +420,11 @@ export function detectFlow(
351
420
  * honor the same selection semantics (cwd / `--all` / `--filter` /
352
421
  * `--affected` / anchored positionals).
353
422
  *
354
- * Returns either the resolved options or an error message string. The
355
- * caller is responsible for prefixing the message with its subcommand
356
- * name (`vx run: <msg>` / `vx watch: <msg>`) and exiting non-zero.
423
+ * Returns the resolved options, an error message, or `nothingSelected` —
424
+ * the selection legitimately resolved to zero projects (nothing changed
425
+ * since the `--affected` base), which is a clean exit, not a failure. The
426
+ * caller prefixes messages with its subcommand name (`vx run: <msg>` /
427
+ * `vx watch: <msg>`).
357
428
  *
358
429
  * Assumes the caller has already populated `parsed.tasks` (e.g. via
359
430
  * the interactive picker for `vx run`).
@@ -362,7 +433,7 @@ export async function resolveRunOptions(
362
433
  parsed: RunArgs,
363
434
  cwd: string,
364
435
  tasks: readonly string[],
365
- ): Promise<RunOptions | { error: string }> {
436
+ ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
366
437
  for (const t of tasks) {
367
438
  const idx = t.indexOf('#')
368
439
  if (idx >= 0) {
@@ -387,13 +458,25 @@ export async function resolveRunOptions(
387
458
  // Project scope applies to bare task names only. Anchored entries
388
459
  // (pkg#task) resolve directly to their own project regardless.
389
460
  const bareTasks = tasks.filter((t) => !t.includes('#'))
461
+ const anchoredTasks = tasks.filter((t) => t.includes('#'))
390
462
  let projects: string[] | undefined
391
463
  if (bareTasks.length === 0) {
392
464
  projects = undefined
393
465
  } else if (filterStrings.length > 0) {
394
466
  const resolved = await resolveFilters(cwd, filterStrings)
395
- if (resolved.error) return { error: resolved.error }
396
- projects = resolved.names
467
+ if ('error' in resolved) return { error: resolved.error }
468
+ if ('empty' in resolved) {
469
+ // "Nothing changed" is a clean exit for the BARE tasks the filter
470
+ // scopes — but it must never cancel an explicitly anchored
471
+ // `pkg#task`, which resolves to its own project regardless of
472
+ // scope. An empty project list keeps the bare tasks unselected
473
+ // while the anchored ones still run.
474
+ if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
475
+ process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
476
+ projects = []
477
+ } else {
478
+ projects = resolved.names
479
+ }
397
480
  } else if (parsed.all) {
398
481
  projects = undefined
399
482
  } else {
@@ -414,6 +497,7 @@ export async function resolveRunOptions(
414
497
  flow: detectFlow(parsed),
415
498
  ...(parsed.frozen ? { frozen: true } : {}),
416
499
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
500
+ ...(parsed.download !== undefined ? { download: parsed.download } : {}),
417
501
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
418
502
  forwardArgs: parsed.forwardArgs,
419
503
  }
@@ -471,6 +555,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
471
555
  process.stderr.write(`vx run: ${resolved.error}\n`)
472
556
  return 1
473
557
  }
558
+ if ('nothingSelected' in resolved) {
559
+ process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
560
+ return 0
561
+ }
474
562
  const opts = resolved
475
563
  // The raw invocation, recorded on the `invocations` row so dashboards
476
564
  // show what was actually run. `args` is everything after `run`.
@@ -480,6 +568,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
480
568
  // graph + probe the cache; the difference is just the formatter.
481
569
  if (parsed.dry !== undefined || parsed.graph !== undefined) {
482
570
  const plan = await planRun(opts)
571
+ if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
572
+ process.stderr.write(
573
+ `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.\n`,
574
+ )
575
+ return 1
576
+ }
483
577
  if (plan.tasks.length === 0) {
484
578
  process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
485
579
  return 1
@@ -499,35 +593,93 @@ export async function runCmd(args: readonly string[]): Promise<number> {
499
593
  return 0
500
594
  }
501
595
 
502
- // Resolve where this run executes. A plugin's `backend` capability wins
503
- // first (e.g. a backend plugin routes to a local-or-hosted service);
504
- // otherwise core's default is pure in-process. Core names no service —
505
- // delegation is entirely a plugin concern.
506
- const request = optionsToRequest(opts)
507
- const root = await findWorkspaceRoot(cwd)
508
- const workspaceConfig = await loadWorkspaceConfig(root)
509
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
510
- const backend = await resolvePluginBackend(
511
- plugins,
512
- {
513
- workspaceRoot: root,
514
- cacheDir: resolveCacheDir(root, workspaceConfig),
515
- warn: (m) => process.stderr.write(`${m}\n`),
516
- request,
517
- },
518
- () => Promise.resolve(localBackend()),
519
- )
520
- const result = await backend.run(request)
596
+ // A run executes in THIS process, always. Where an individual task's
597
+ // command runs is the `executor` capability's business (per task, with
598
+ // the scheduler, cache, retries and telemetry unchanged above it); there
599
+ // is no whole-run delegation seam to consult.
600
+ const summary = await runOrchestrator(opts)
601
+ const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
521
602
  if (parsed.verbosity > 0) printSummary(result)
522
- // Report generation is post-run, gated on the flag — zero cost when
523
- // absent. Goes to stdout (NOT the status logger) so it stays
524
- // machine-clean for `vx run ci --report=markdown >> $GITHUB_STEP_SUMMARY`.
525
- if (parsed.report === 'markdown') {
526
- process.stdout.write(formatRunReportMarkdown(result))
603
+ // Report generation is post-run, gated on the flags — zero cost when
604
+ // both are absent. Rendered once, however many sinks asked for it.
605
+ if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
606
+ const md = formatRunReportMarkdown(result)
607
+ // `--report` writes stdout. The report ITSELF is machine-clean, but
608
+ // stdout is not vx's alone — the status logger writes there too, so
609
+ // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
610
+ // meter bar and `::group::` command above the table. `--report-file`
611
+ // is the redirect-free form.
612
+ if (parsed.report === 'markdown') process.stdout.write(md)
613
+ if (parsed.reportFile !== undefined) {
614
+ const target = path.resolve(cwd, parsed.reportFile)
615
+ try {
616
+ // APPEND, not overwrite: the flag exists for `$GITHUB_STEP_SUMMARY`,
617
+ // which GitHub documents as append-only and which other steps in the
618
+ // same job also write to. Truncating it would silently destroy their
619
+ // content, and would make replacing the documented `>>` recipe a
620
+ // behaviour change rather than a drop-in.
621
+ await appendFile(target, md)
622
+ } catch (err) {
623
+ // Same contract as --summarize / --profile: the run already
624
+ // happened, so a write failure is reported, not fatal.
625
+ const message = err instanceof Error ? err.message : String(err)
626
+ process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
627
+ }
628
+ }
527
629
  }
528
630
  return result.ok ? 0 : 1
529
631
  }
530
632
 
633
+ /**
634
+ * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
635
+ * `orphans` (workspace-relative paths belonging to no project).
636
+ *
637
+ * Reached ONLY when a changed path belongs to no project, so a run whose every
638
+ * change is inside a project never gets here — the scoped-config-loading win
639
+ * is untouched for the common case.
640
+ *
641
+ * `vx-lock.json` FIRST when present: it holds the resolved configs, so CI —
642
+ * where `--affected` matters most and the lock is committed — answers with
643
+ * zero evaluation. Without a lock this evaluates configs, which is the honest
644
+ * cost of the question: nothing cheaper can know which globs a program
645
+ * declares. A config that fails to load is skipped rather than failing the
646
+ * run, matching the deliberate Turbo-like rule that a broken out-of-scope
647
+ * config does not fail a scoped run.
648
+ */
649
+ async function workspaceGlobOwners(
650
+ root: string,
651
+ projects: readonly ProjectMeta[],
652
+ orphans: readonly string[],
653
+ ): Promise<string[]> {
654
+ const lock = await readLockfile(root)
655
+ const owners: string[] = []
656
+ const declaresMatch = (config: ProjectConfig): boolean => {
657
+ for (const task of Object.values(config.tasks ?? {})) {
658
+ const globs = task.cache?.inputs?.workspaceFiles
659
+ if (globs === undefined) continue
660
+ if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
661
+ }
662
+ return false
663
+ }
664
+ await Promise.all(
665
+ projects.map(async (meta) => {
666
+ const locked = lock?.projects[meta.name]
667
+ if (locked !== undefined) {
668
+ if (declaresMatch(locked.config)) owners.push(meta.name)
669
+ return
670
+ }
671
+ if (meta.configPath === null || meta.configPath === '') return
672
+ try {
673
+ if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
674
+ } catch {
675
+ // A config that will not load cannot be shown to declare a matching
676
+ // glob; surfacing it here would fail a run the loader itself tolerates.
677
+ }
678
+ }),
679
+ )
680
+ return owners
681
+ }
682
+
531
683
  async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
532
684
  const root = await findWorkspaceRoot(cwd)
533
685
  const ws = await loadWorkspace(root)
@@ -546,10 +698,9 @@ async function findCwdProject(cwd: string): Promise<string | null> {
546
698
  return best?.name ?? null
547
699
  }
548
700
 
549
- async function resolveFilters(
550
- cwd: string,
551
- raw: string[],
552
- ): Promise<{ names?: string[]; error?: string }> {
701
+ type FilterResolution = { names: string[] } | { error: string } | { empty: string }
702
+
703
+ async function resolveFilters(cwd: string, raw: string[]): Promise<FilterResolution> {
553
704
  const root = await findWorkspaceRoot(cwd)
554
705
  const projects = await loadWorkspaceProjects(cwd)
555
706
  const graph = buildPackageGraph(projects)
@@ -562,7 +713,12 @@ async function resolveFilters(
562
713
  for (const f of parsed) {
563
714
  if (f.gitSince === undefined) continue
564
715
  try {
565
- const names = await affectedProjects({ workspaceRoot: root, since: f.gitSince, projects })
716
+ const names = await affectedProjects({
717
+ workspaceRoot: root,
718
+ since: f.gitSince,
719
+ projects,
720
+ workspaceGlobOwners: (orphans) => workspaceGlobOwners(root, projects, orphans),
721
+ })
566
722
  affectedByFilter.set(f, names)
567
723
  } catch (err) {
568
724
  const msg = err instanceof Error ? err.message : String(err)
@@ -570,8 +726,29 @@ async function resolveFilters(
570
726
  }
571
727
  }
572
728
 
573
- const selected = applyFilters({ filters: parsed, projects, graph, affectedByFilter })
729
+ const selected = applyFilters({
730
+ filters: parsed,
731
+ projects,
732
+ graph,
733
+ affectedByFilter,
734
+ // A `[<since>]` selector matching nothing is the ordinary "nothing
735
+ // changed" outcome, reported below; only a name/path pattern that
736
+ // matched nothing is worth flagging as a probable typo.
737
+ onNoMatch: (f) => {
738
+ if (f.gitSince === undefined) {
739
+ process.stderr.write(`vx: filter "${f.raw}" matched no projects\n`)
740
+ }
741
+ },
742
+ })
574
743
  if (selected.size === 0) {
744
+ // "Nothing changed" is a legitimate outcome, not a typo — a docs-only
745
+ // commit must not red `vx run … --affected=origin/main`. Only report an
746
+ // error when the user named something concrete that failed to resolve.
747
+ const includes = parsed.filter((f) => !f.negate)
748
+ if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
749
+ const refs = includes.map((f) => f.gitSince).join(', ')
750
+ return { empty: `nothing affected since ${refs}` }
751
+ }
575
752
  return { error: `no projects matched filter(s): ${raw.join(', ')}` }
576
753
  }
577
754
  return { names: [...selected].sort() }
@@ -681,3 +858,18 @@ function formatRow(o: OutcomeView): { task: string; status: string; duration: st
681
858
  duration: `${o.durationMs}ms`,
682
859
  }
683
860
  }
861
+
862
+ /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
863
+ function didYouMeanFlag(flag: string): string {
864
+ const name = flag.replace(/=.*$/, '')
865
+ let best: string | undefined
866
+ let bestD = 3
867
+ for (const f of documentedFlags('run')) {
868
+ const d = editDistance(name, f)
869
+ if (d < bestD) {
870
+ bestD = d
871
+ best = f
872
+ }
873
+ }
874
+ return best === undefined || best === name ? '' : ` (did you mean ${best}?)`
875
+ }
@@ -1,6 +1,6 @@
1
1
  // `vx upgrade [tag]` — self-update the compiled binary in place.
2
- // Mirrors install.sh: download the release asset for this os/arch,
3
- // write next to the current executable, atomic rename over it.
2
+ // Downloads the GitHub release asset for this os/arch, writes next to
3
+ // the current executable, atomic rename over it.
4
4
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
5
5
  // deno upgrade — "update" is what package managers do to indexes.
6
6
 
@@ -24,7 +24,7 @@ export function isBunfsPath(p: string): boolean {
24
24
  * `Bun.main` (and argv[1]) rather than `import.meta.path`: with
25
25
  * `--minify --bytecode` — vx's release build flags — `import.meta.path`
26
26
  * reports the ORIGINAL SOURCE path, not the bunfs path, so the old
27
- * check silently failed for every curl-installed binary and `vx
27
+ * check silently failed for every installed binary and `vx
28
28
  * upgrade` refused with "running from source". `Bun.main` stays the
29
29
  * bunfs path under every compile-flag combination.
30
30
  */
@@ -68,7 +68,7 @@ export async function replaceBinary(dest: string, url: string): Promise<void> {
68
68
  const msg = err instanceof Error ? err.message : String(err)
69
69
  throw new UserError(
70
70
  `vx upgrade: could not replace ${dest} (${msg}) — ` +
71
- `check permissions, or reinstall via install.sh with VX_INSTALL_DIR set`,
71
+ `check permissions, or reinstall with npm install -g @vzn/vx`,
72
72
  )
73
73
  }
74
74
  }
@@ -82,8 +82,9 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
82
82
  }
83
83
  if (!isCompiledBinary()) {
84
84
  throw new UserError(
85
- 'vx upgrade only works for the compiled binary (install.sh). ' +
86
- 'You are running from source — use git pull instead.',
85
+ 'vx upgrade only works for the compiled binary. ' +
86
+ 'You are running from source — use git pull instead. ' +
87
+ '(npm installs update with: npm install -g @vzn/vx@latest)',
87
88
  )
88
89
  }
89
90
  const asset = assetName()