@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
package/src/cache/zstd.ts CHANGED
@@ -194,15 +194,21 @@ export async function decodedTar(
194
194
  }
195
195
  return oneChunk(await zstdDecompressBounded(source, hash, cap))
196
196
  }
197
+ // A BunFile reads no further than the size its first stat cached, and a
198
+ // save or ingest of the same key may rename a longer copy over the path
199
+ // after that stat: the read came back cut short, the restore called the
200
+ // new artifact corrupt and dropped it. The size only picks the decode;
201
+ // each read goes through a handle that has not stat the path yet.
202
+ const fresh = (): Bun.BunFile => Bun.file(source.name!)
197
203
  if (source.size <= STREAM_DECODE_FROM) {
198
- const bytes = await source.bytes()
204
+ const bytes = await fresh().bytes()
199
205
  if (assertDeclaredSize(bytes, hash, cap) === null || !isOneFrame(bytes)) {
200
- return zstdDecodeStream(source, hash, cap)
206
+ return zstdDecodeStream(new Blob([bytes]), hash, cap)
201
207
  }
202
208
  return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
203
209
  }
204
- assertDeclaredSize(await source.slice(0, 32).bytes(), hash, cap)
205
- return zstdDecodeStream(source, hash, cap)
210
+ assertDeclaredSize(await fresh().slice(0, 32).bytes(), hash, cap)
211
+ return zstdDecodeStream(fresh(), hash, cap)
206
212
  }
207
213
 
208
214
  /**
package/src/cli/cache.ts CHANGED
@@ -85,10 +85,15 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
85
85
  if (a === '--older-than' || a?.startsWith('--older-than=')) {
86
86
  const v = a === '--older-than' ? args[++i] : a.slice('--older-than='.length)
87
87
  if (v === undefined || v === '') {
88
- return { error: `--older-than requires a value (e.g. 30d, 24h, 60m)` }
88
+ return {
89
+ error: `--older-than requires a value: a duration like 30d, 24h or 60m${seeHelp('cache')}`,
90
+ }
89
91
  }
90
92
  const ms = parseDuration(v)
91
- if (ms === null) return { error: `invalid duration: ${v} (e.g. 30d, 24h, 60m)` }
93
+ if (ms === null)
94
+ return {
95
+ error: `--older-than must be a duration like 30d, 24h or 60m (got ${v})${seeHelp('cache')}`,
96
+ }
92
97
  // `0d` evicts everything ever cached. That is far more often a
93
98
  // computed-to-zero retention than an intent, and no other flag
94
99
  // combination expresses "wipe the cache" — so refuse it and name
@@ -102,10 +107,11 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
102
107
  } else if (a === '--max-size' || a?.startsWith('--max-size=')) {
103
108
  const v = a === '--max-size' ? args[++i] : a.slice('--max-size='.length)
104
109
  if (v === undefined || v === '') {
105
- return { error: `--max-size requires a value (e.g. 500M, 1G)` }
110
+ return { error: `--max-size requires a value: a size like 500M or 1G${seeHelp('cache')}` }
106
111
  }
107
112
  const bytes = parseSize(v)
108
- if (bytes === null) return { error: `invalid size: ${v} (e.g. 500M, 1G)` }
113
+ if (bytes === null)
114
+ return { error: `--max-size must be a size like 500M or 1G (got ${v})${seeHelp('cache')}` }
109
115
  if (bytes === 0) {
110
116
  return {
111
117
  error: `--max-size 0 would evict every entry — to clear the cache, delete the cache store \`vx info\` names`,
@@ -185,19 +191,9 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
185
191
  printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
186
192
  return 0
187
193
  }
188
- if (parsed.dryRun === true) {
189
- const earlier = await Cache.orphansBeforeReset(dir)
190
- if (earlier !== null) {
191
- warnToStderr(
192
- `[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
193
- )
194
- printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
195
- return 0
196
- }
197
- }
198
194
  const cache =
199
195
  parsed.dryRun === true
200
- ? new Cache(dir, undefined, undefined, undefined, 'inspect', storeRoot)
196
+ ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
201
197
  : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
202
198
  // A prune deletes rows and artifacts; a cache this user cannot write is
203
199
  // refused up front with the directory named, as a run refuses it, rather
@@ -8,6 +8,13 @@
8
8
  import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
9
9
  import { nearest } from '../util/index.js'
10
10
 
11
+ /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
+ export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
13
+ '--format': ['pretty', 'json'],
14
+ '--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
15
+ '--download': ['all', 'toplevel', 'none'],
16
+ }
17
+
11
18
  export type CompletionShell = 'bash' | 'zsh' | 'fish'
12
19
  const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
13
20
 
@@ -36,6 +43,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
36
43
  ...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
37
44
  ],
38
45
  }))
46
+ // `vx run --output-logs <Tab>` offered the flags again; a flag with a
47
+ // fixed value set offers that set.
48
+ const values = table.flatMap((t) =>
49
+ t.words.flatMap((w) => {
50
+ const vs = FLAG_VALUES[w]
51
+ return vs === undefined ? [] : [{ verb: t.verb, flag: w, values: vs.join(' ') }]
52
+ }),
53
+ )
39
54
  switch (shell) {
40
55
  case 'bash':
41
56
  return [
@@ -43,10 +58,23 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
43
58
  '_vx() {',
44
59
  ' local cur="${COMP_WORDS[COMP_CWORD]}"',
45
60
  ' local verb="${COMP_WORDS[1]}"',
61
+ ' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
46
62
  ' if [ "$COMP_CWORD" -eq 1 ]; then',
47
63
  ` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
48
64
  ' return',
49
65
  ' fi',
66
+ // bash splits `--format=js` at the `=` (COMP_WORDBREAKS).
67
+ ' if [ "$cur" = "=" ]; then',
68
+ ' cur=""',
69
+ ' elif [ "$prev" = "=" ]; then',
70
+ ' prev="${COMP_WORDS[COMP_CWORD-2]}"',
71
+ ' fi',
72
+ ' case "$verb $prev" in',
73
+ ...values.map(
74
+ (v) =>
75
+ ` "${v.verb} ${v.flag}") COMPREPLY=($(compgen -W "${v.values}" -- "$cur")); return ;;`,
76
+ ),
77
+ ' esac',
50
78
  ' case "$verb" in',
51
79
  ...table.map(
52
80
  (t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
@@ -66,6 +94,9 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
66
94
  ` compadd -- ${verbs.join(' ')}`,
67
95
  ' return',
68
96
  ' fi',
97
+ ' case "${words[2]} ${words[CURRENT-1]}" in',
98
+ ...values.map((v) => ` "${v.verb} ${v.flag}") compadd -- ${v.values}; return ;;`),
99
+ ' esac',
69
100
  ' case "${words[2]}" in',
70
101
  ...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
71
102
  ' esac',
@@ -86,11 +117,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
86
117
  'complete -c vx -f',
87
118
  ...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
88
119
  ...table.flatMap((t) =>
89
- t.words.map((w) =>
90
- w.startsWith('--')
91
- ? `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -l ${w.slice(2)}`
92
- : `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -a ${w}`,
93
- ),
120
+ t.words.map((w) => {
121
+ const on = `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}"`
122
+ if (!w.startsWith('--')) return `${on} -a ${w}`
123
+ const vs = FLAG_VALUES[w]
124
+ return vs === undefined
125
+ ? `${on} -l ${w.slice(2)}`
126
+ : `${on} -l ${w.slice(2)} -x -a "${vs.join(' ')}"`
127
+ }),
94
128
  ),
95
129
  '',
96
130
  ].join('\n')
@@ -111,7 +145,9 @@ export async function completionsCmd(
111
145
  return 1
112
146
  }
113
147
  const one = shell !== undefined && args.length === 1
114
- const best = one ? nearest(shell, SHELLS) : undefined
148
+ // One edit: shell names are three or four letters, so two edits took
149
+ // `tcsh` to `bash`, a different shell.
150
+ const best = one ? nearest(shell, SHELLS, 1) : undefined
115
151
  process.stderr.write(
116
152
  `vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
117
153
  )
@@ -36,6 +36,17 @@ const list = (v: string | undefined): string[] =>
36
36
  .map((s) => s.trim())
37
37
  .filter((s) => s !== '')
38
38
 
39
+ // Nx's `--projects` labels (find-matching-projects, 23.2.1): `name:` and
40
+ // `directory:` say which match to make; vx's filter says it by form. `tag:`
41
+ // is a filter form already.
42
+ const nxPattern = (p: string): string => {
43
+ const neg = p.startsWith('!') ? '!' : ''
44
+ const s = neg === '' ? p : p.slice(1)
45
+ if (s.startsWith('name:')) return `${neg}${s.slice(5)}`
46
+ if (s.startsWith('directory:')) return `${neg}./${s.slice(10)}`
47
+ return p
48
+ }
49
+
39
50
  const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
40
51
 
41
52
  export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
@@ -142,7 +153,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
142
153
  outcome: 'same',
143
154
  vx: '`--output-logs full|errors-only|hash-only|none`',
144
155
  },
145
- { runner: 'turbo', names: ['--no-cache'], value: false, outcome: 'same', vx: '`--no-cache`' },
156
+ {
157
+ runner: 'turbo',
158
+ names: ['--no-cache'],
159
+ value: false,
160
+ outcome: 'same',
161
+ // Turbo 2.11's own help: "Equivalent to `--cache=local:r,remote:r`".
162
+ vx: "`--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r`",
163
+ },
146
164
  {
147
165
  runner: 'turbo',
148
166
  names: ['--cache'],
@@ -357,7 +375,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
357
375
  names: ['--preflight'],
358
376
  value: false,
359
377
  outcome: 'refuse',
360
- vx: '`turboCache()` sends no CORS preflight: drop it',
378
+ vx: 'set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1`',
361
379
  },
362
380
  {
363
381
  runner: 'turbo',
@@ -407,12 +425,15 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
407
425
  names: ['-p', '--projects'],
408
426
  value: true,
409
427
  outcome: 'alias',
410
- vx: "`--filter <pattern>`, one per project; a list opening with `!` starts from all (`--filter '*'`)",
428
+ vx: "`--filter <pattern>`, one per project (`directory:<d>` is `./<d>`, `name:<n>` is `<n>`); a list opening with `!` starts from all (`--filter '*'`)",
411
429
  // Nx reads a leading exclusion as "all projects but": `-p '!b,a'` is
412
430
  // every project except b, where vx's filters alone would select a.
413
431
  to: (v) => {
414
432
  const ps = list(v)
415
- return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => ['--filter', p])
433
+ return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
434
+ '--filter',
435
+ nxPattern(p),
436
+ ])
416
437
  },
417
438
  },
418
439
  {
@@ -421,7 +442,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
421
442
  value: true,
422
443
  outcome: 'alias',
423
444
  vx: "`--filter '!<pattern>'`, one per project",
424
- to: (v) => list(v).flatMap((p) => ['--filter', `!${p}`]),
445
+ to: (v) => list(v).flatMap((p) => ['--filter', `!${nxPattern(p)}`]),
425
446
  },
426
447
  {
427
448
  runner: 'nx',
package/src/cli/help.ts CHANGED
@@ -111,7 +111,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
111
111
  ' vx lock [--check]',
112
112
  ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
113
113
  ' vx upgrade [TAG]',
114
- ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
114
+ ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
115
115
  ' vx info [--format pretty|json] [--cache-dir <path>]',
116
116
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
117
117
  ' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
@@ -140,8 +140,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
140
140
  ' to its flags; unnamed layers keep their value. Examples:',
141
141
  ' local:rw,remote:r (remote read-only), remote: (remote off),',
142
142
  ' local:r (local read-only).',
143
- ' --cache-dir <path> Cache directory override (cwd-relative). Beats the workspace',
144
- ' cacheDir field + the .vx/cache default.',
143
+ ' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
144
+ ' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
145
145
  ' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
146
146
  " without their own exec.retries; explicit config wins). Doesn't",
147
147
  ' affect cache keys.',
@@ -209,6 +209,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
209
209
  ' evaluation, not the lock), one block per task.',
210
210
  " vx show <pkg>#<task> Print a single task's resolved config.",
211
211
  ' vx show <task> That task in every project declaring it.',
212
+ " --filter <pattern> The list (or a task's projects) as `vx run --filter` selects.",
213
+ ' --affected[=<ref>] Only what `vx run --affected` selects: changed and dependents.',
212
214
  ' --format <fmt> pretty (default) | json.',
213
215
  ' vx info Workspace doctor: vx/bun/git versions, project +',
214
216
  ' task counts, cache dir/entries/size, recent runs,',
package/src/cli/init.ts CHANGED
@@ -369,7 +369,7 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
369
369
  const withScripts = dirs
370
370
  .filter((dir) => {
371
371
  const scripts = (
372
- JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as {
372
+ JSON.parse(readText(path.join(dir, 'package.json')).replace(/^\uFEFF/, '') || '{}') as {
373
373
  scripts?: unknown
374
374
  }
375
375
  ).scripts
package/src/cli/last.ts CHANGED
@@ -60,7 +60,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
60
60
  if (spaced) i++
61
61
  const n = Number(lv)
62
62
  if (!Number.isInteger(n) || n < 1 || n > 500) {
63
- return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
63
+ return {
64
+ ...out,
65
+ error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
66
+ }
64
67
  }
65
68
  out.list = n
66
69
  continue
@@ -29,6 +29,7 @@ export function formatPlanText(plan: RunPlan): string {
29
29
  let miss = 0
30
30
  let nocache = 0
31
31
  let noop = 0
32
+ let idle = 0
32
33
  for (const t of real) {
33
34
  // `@noop`: the run succeeds it without running, whatever its key says.
34
35
  const skipped = t.executor === 'noop'
@@ -56,6 +57,7 @@ export function formatPlanText(plan: RunPlan): string {
56
57
  else if (t.cacheStatus === 'hit-remote') remote++
57
58
  else if (t.cacheStatus === 'miss') miss++
58
59
  else if (t.cacheStatus === 'no-cache') nocache++
60
+ else if (t.cacheStatus === 'not-started') idle++
59
61
  }
60
62
 
61
63
  const hitParts: string[] = []
@@ -67,6 +69,7 @@ export function formatPlanText(plan: RunPlan): string {
67
69
  if (miss > 0) summary.push(`${miss} would run`)
68
70
  if (nocache > 0) summary.push(`${nocache} no-cache`)
69
71
  if (noop > 0) summary.push(`${noop} noop`)
72
+ if (idle > 0) summary.push(`${idle} not started`)
70
73
  lines.push('')
71
74
  lines.push(summary.join(', ') + '.')
72
75
 
@@ -199,6 +202,8 @@ function symbolFor(s: CacheStatus): string {
199
202
  return '·'
200
203
  case 'group':
201
204
  return '○'
205
+ case 'not-started':
206
+ return '−'
202
207
  }
203
208
  }
204
209
 
@@ -214,6 +219,8 @@ function describe(s: CacheStatus): string {
214
219
  return 'no-cache (would exec)'
215
220
  case 'group':
216
221
  return 'group task'
222
+ case 'not-started':
223
+ return 'not started (unneeded)'
217
224
  }
218
225
  }
219
226
 
@@ -229,5 +236,7 @@ function dotColor(s: CacheStatus): string {
229
236
  return '#e5e7eb' // gray-200
230
237
  case 'group':
231
238
  return '#f5d0fe' // fuchsia-200
239
+ case 'not-started':
240
+ return '#ffffff'
232
241
  }
233
242
  }
@@ -20,8 +20,8 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
20
20
  },
21
21
  executor: {
22
22
  plugin:
23
- "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n const child = Bun.spawn([sh, '-c', [req.command, ...req.forwardArgs].join(' ')], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
- test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n",
23
+ "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
+ test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
25
25
  },
26
26
  graph: {
27
27
  plugin:
package/src/cli/run.ts CHANGED
@@ -3,7 +3,6 @@ import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
4
  import { translateForeign } from './foreign-flags.js'
5
5
  import { flagHint, seeHelp } from './help.js'
6
- import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
7
6
  import {
8
7
  planRun,
9
8
  formatRunReportMarkdown,
@@ -13,12 +12,11 @@ import {
13
12
  type RunOptions,
14
13
  type RunResult,
15
14
  } from '../orchestrator/index.js'
16
- import type { ContinueMode } from '../graph/index.js'
15
+ import type { ContinueMode, TaskOutcome } from '../graph/index.js'
17
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
18
- import { findCwdSelection, pickTask, resolveFilters } from './select.js'
17
+ import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
19
18
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
20
- import { loadCliWorkspace } from './workspace-config.js'
21
- import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
19
+ import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
22
20
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
23
21
 
24
22
  export interface RunArgs {
@@ -113,7 +111,7 @@ export function parseConcurrency(v: string, cpus = machineParallelism()): number
113
111
  }
114
112
 
115
113
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
116
- * its refusals pointed at `vx run --help` (M-58). */
114
+ * its flag hints named `vx run` (M-58). The caller appends the help pointer. */
117
115
  export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
118
116
  const out: RunArgs = {
119
117
  tasks: [],
@@ -185,7 +183,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
185
183
  } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
186
184
  return {
187
185
  ...out,
188
- error: `unknown flag: ${a} (the flag is --exclude-dependencies)${seeHelp(verb)}`,
186
+ error: `unknown flag: ${a} (the flag is --exclude-dependencies)`,
189
187
  }
190
188
  } else if (a === '--exclude-dependencies') {
191
189
  out.excludeDependencies = 'all'
@@ -215,7 +213,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
215
213
  return { ...out, error: `--retry requires a value (a non-negative integer)` }
216
214
  const n = parseDecimalInt(v)
217
215
  if (n === null) {
218
- return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
216
+ return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
219
217
  }
220
218
  out.retries = n
221
219
  } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
@@ -234,19 +232,31 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
234
232
  }
235
233
  }
236
234
  if (n === null || n <= 0) {
237
- return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
235
+ return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
238
236
  }
239
237
  out.timeout = n
240
238
  } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
241
239
  const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
240
+ if (v === undefined || v === '') {
241
+ return {
242
+ ...out,
243
+ error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
244
+ }
245
+ }
242
246
  if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
243
- return { ...out, error: `--output-logs must be full, errors-only, hash-only, or none` }
247
+ return {
248
+ ...out,
249
+ error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
250
+ }
244
251
  }
245
252
  out.outputLogs = v
246
253
  } else if (a === '--download' || a?.startsWith('--download=')) {
247
254
  const v = a === '--download' ? before[++i] : a.slice('--download='.length)
255
+ if (v === undefined || v === '') {
256
+ return { ...out, error: `--download requires a value (all, toplevel, or none)` }
257
+ }
248
258
  if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
249
- return { ...out, error: `--download must be all, toplevel, or none` }
259
+ return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
250
260
  }
251
261
  out.download = v
252
262
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
@@ -301,8 +311,11 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
301
311
  out.continueMode = 'always'
302
312
  } else if (a?.startsWith('--continue=')) {
303
313
  const v = a.slice('--continue='.length)
314
+ if (v === '') {
315
+ return { ...out, error: `--continue= requires a mode (never, deps-ok, or always)` }
316
+ }
304
317
  if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
305
- return { ...out, error: `--continue must be never, deps-ok, or always` }
318
+ return { ...out, error: `--continue must be never, deps-ok, or always (got ${v})` }
306
319
  }
307
320
  out.continueMode = v
308
321
  } else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
@@ -366,7 +379,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
366
379
  }
367
380
  out.report = fmt
368
381
  } else if (a !== undefined && a.startsWith('-')) {
369
- return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}${seeHelp(verb)}` }
382
+ return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
370
383
  } else if (a !== undefined) {
371
384
  out.tasks.push(a)
372
385
  }
@@ -446,20 +459,9 @@ async function scopeFilters(
446
459
  const filterStrings = [...parsed.filters]
447
460
  let affectedFilter: string | undefined
448
461
  if (parsed.affected !== undefined) {
449
- const root = await findWorkspaceRoot(cwd)
450
- let base = parsed.affected
451
- // The workspace's `affectedBase` — or a plugin's `config` stage, from
452
- // nx.json's `defaultBase` or `TURBO_SCM_BASE` — comes before the guess.
453
- if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
454
- if (base === '') {
455
- try {
456
- base = await defaultAffectedBase(root)
457
- } catch (err) {
458
- if (!isUserError(err)) throw err
459
- return { error: err.message }
460
- }
461
- }
462
- affectedFilter = `...[${base}]`
462
+ const f = await affectedFilterFor(cwd, parsed.affected)
463
+ if (typeof f === 'object') return f
464
+ affectedFilter = f
463
465
  filterStrings.unshift(affectedFilter)
464
466
  }
465
467
  return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
@@ -484,6 +486,7 @@ export async function resolveRunOptions(
484
486
  parsed: RunArgs,
485
487
  cwd: string,
486
488
  tasks: readonly string[],
489
+ verb: 'run' | 'watch' = 'run',
487
490
  ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
488
491
  for (const t of tasks) {
489
492
  const idx = t.indexOf('#')
@@ -555,8 +558,7 @@ export async function resolveRunOptions(
555
558
  const nx = await nxTargetHint(tasks, cwd)
556
559
  if (nx !== null) return { error: nx }
557
560
  return {
558
- error:
559
- 'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
561
+ error: `not inside a project: run from a project directory, or pass --all or --filter <pattern>${seeHelp(verb)}`,
560
562
  }
561
563
  }
562
564
  projects = [cwdProject.name]
@@ -612,7 +614,7 @@ const TASKS_SHOWN = 12
612
614
  export async function runCmd(args: readonly string[]): Promise<number> {
613
615
  const parsed = parseRunArgs(args)
614
616
  if (parsed.error) {
615
- process.stderr.write(`vx run: ${parsed.error}\n`)
617
+ process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
616
618
  return 1
617
619
  }
618
620
 
@@ -724,6 +726,25 @@ export async function runCmd(args: readonly string[]): Promise<number> {
724
726
  return 0
725
727
  }
726
728
 
729
+ // The report prints above the footer: nothing prints below it (owner).
730
+ // Rendered once, however many sinks asked for it; a kept server's crash
731
+ // after the footer re-renders the file's copy (C-53).
732
+ const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
733
+ let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
734
+ if (wantsReport) {
735
+ opts.beforeFooter = (outcomes, ok) => {
736
+ reported = {
737
+ outcomes,
738
+ md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
739
+ }
740
+ // `--report` writes stdout. The report ITSELF is machine-clean, but
741
+ // stdout is not vx's alone — the status logger writes there too, so
742
+ // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
743
+ // meter bar and `::group::` command above the table. `--report-file`
744
+ // is the redirect-free form.
745
+ return parsed.report === 'markdown' ? reported.md : ''
746
+ }
747
+ }
727
748
  // A run executes in THIS process, always. Where an individual task's
728
749
  // command runs is the `executor` capability's business (per task, with
729
750
  // the scheduler, cache, retries and telemetry unchanged above it); there
@@ -733,17 +754,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
733
754
  process.stderr.write(`vx run: ${summary.refused}\n`)
734
755
  return 1
735
756
  }
736
- const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
737
- // Report generation is post-run, gated on the flags — zero cost when
738
- // both are absent. Rendered once, however many sinks asked for it.
739
- if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
740
- const md = formatRunReportMarkdown(result)
741
- // `--report` writes stdout. The report ITSELF is machine-clean, but
742
- // stdout is not vx's alone — the status logger writes there too, so
743
- // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
744
- // meter bar and `::group::` command above the table. `--report-file`
745
- // is the redirect-free form.
746
- if (parsed.report === 'markdown') process.stdout.write(md)
757
+ if (wantsReport) {
758
+ const md =
759
+ reported?.outcomes === summary.outcomes
760
+ ? reported.md
761
+ : formatRunReportMarkdown({
762
+ ok: summary.ok,
763
+ outcomes: summary.outcomes.map(projectOutcome),
764
+ })
747
765
  if (parsed.reportFile !== undefined) {
748
766
  const target = path.resolve(cwd, parsed.reportFile)
749
767
  try {
@@ -766,5 +784,5 @@ export async function runCmd(args: readonly string[]): Promise<number> {
766
784
  }
767
785
  }
768
786
  }
769
- return result.ok ? 0 : 1
787
+ return summary.ok ? 0 : 1
770
788
  }