@vzn/vx 0.0.630 → 0.0.632

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 (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. package/src/workspace/workspace.ts +1 -0
package/src/cli/init.ts CHANGED
@@ -1,8 +1,9 @@
1
- // `vx init [--dry] [--force] [--mjs]` — a workspace from nowhere: one vx.config.ts
2
- // per package from its package.json scripts, and the workspace file every
3
- // run needs. In a Turbo or Nx repo it writes only the workspace file,
4
- // declaring `turbo()` or `nx()` from `@vzn/vx-migrate`, which read the
5
- // runner's own config live (`adopt`).
1
+ // `vx init [--dry] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
2
+ // one vx.config.ts per package from its package.json scripts, and the
3
+ // workspace file every run needs. In a Turbo or Nx repo it hands over to
4
+ // `@vzn/vx-migrate` (`migrate`), which asks a terminal whether to write
5
+ // native config; `--keep` writes only the workspace file, declaring
6
+ // `turbo()` or `nx()`, which read the runner's own config live (`adopt`).
6
7
  // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
7
8
  // test instead (plugin-templates.ts, the examples the gate runs).
8
9
 
@@ -11,6 +12,8 @@ import { mkdir, unlink } from 'node:fs/promises'
11
12
  import path from 'node:path'
12
13
  import { flagHint, seeHelp } from './help.js'
13
14
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
15
+ import { isCompiledBinary } from './upgrade.js'
16
+ import { VERSION } from '../version.js'
14
17
  import { isUserError, relPosix, UserError } from '../util/index.js'
15
18
  import {
16
19
  applyMigration,
@@ -29,6 +32,8 @@ interface InitArgs {
29
32
  mjs: boolean
30
33
  /** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
31
34
  plugin?: string
35
+ /** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
36
+ mode?: 'native' | 'keep'
32
37
  error?: string
33
38
  }
34
39
 
@@ -49,10 +54,22 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
49
54
  } else if (a === '--dry') out.dry = true
50
55
  else if (a === '--force') out.force = true
51
56
  else if (a === '--mjs') out.mjs = true
52
- else if (a.startsWith('-'))
57
+ else if (a === '--native' || a === '--keep') {
58
+ const mode = a === '--native' ? 'native' : 'keep'
59
+ if (out.mode !== undefined && out.mode !== mode) {
60
+ return {
61
+ ...out,
62
+ error: `--native and --keep are two answers to one question; pass one${seeHelp('init')}`,
63
+ }
64
+ }
65
+ out.mode = mode
66
+ } else if (a.startsWith('-'))
53
67
  return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
54
68
  else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
55
69
  }
70
+ if (out.plugin !== undefined && out.mode !== undefined) {
71
+ return { ...out, error: `--${out.mode} does not combine with --plugin${seeHelp('init')}` }
72
+ }
56
73
  if (out.plugin !== undefined && out.mjs) {
57
74
  return {
58
75
  ...out,
@@ -73,7 +90,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
73
90
  // "a workspace from nowhere" still needs a package.json to start from,
74
91
  // and the lookup's own refusal named no next step.
75
92
  const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
76
- if (isUserError(err) && err.message.startsWith('Could not find a workspace root')) {
93
+ if (isUserError(err) && err.code === 'VX_E_NO_WORKSPACE') {
77
94
  throw new UserError(
78
95
  'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
79
96
  )
@@ -95,14 +112,42 @@ export async function initCmd(args: readonly string[]): Promise<number> {
95
112
  break
96
113
  }
97
114
  }
98
- // A Turbo or Nx repo already says its tasks: the runner's own config is
99
- // the source, and `turbo()` / `nx()` read it live. Writing a config per
100
- // package from the scripts dropped every edge turbo.json declares (the
101
- // first-five-minutes walk, 2026-09-28); the workspace file alone is the
102
- // whole adoption, and `bunx @vzn/vx-migrate` stays for freezing it.
103
- if (turbo !== undefined) return adopt(root, 'turbo', turbo, parsed)
104
- if (await Bun.file(path.join(root, 'nx.json')).exists()) {
105
- return adopt(root, 'nx', 'nx.json', parsed)
115
+ // A Turbo or Nx repo already says its tasks: writing a config per package
116
+ // from the scripts dropped every edge turbo.json declares (the
117
+ // first-five-minutes walk, 2026-09-28). vx-migrate maps the runner's own
118
+ // config, so `vx init` is the one command (M, 2026-10-08); its `--keep`
119
+ // comes back here for the workspace file alone.
120
+ const runner: ['turbo' | 'nx', string] | undefined =
121
+ turbo !== undefined
122
+ ? ['turbo', turbo]
123
+ : (await Bun.file(path.join(root, 'nx.json')).exists())
124
+ ? ['nx', 'nx.json']
125
+ : undefined
126
+ if (runner !== undefined) {
127
+ if (parsed.mode === 'keep') return adopt(root, ...runner, parsed)
128
+ // A second `vx init` after the native migration refused to overwrite
129
+ // the configs it wrote, exit 1, after a package fetch: the first-run
130
+ // walk (2026-10-09). Set up is an answer, with the next step.
131
+ const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
132
+ if (
133
+ existing !== undefined &&
134
+ !parsed.force &&
135
+ !parsed.dry &&
136
+ parsed.mode === undefined &&
137
+ metas.some((m) => m.configPath !== null) &&
138
+ !readFileSync(path.join(root, existing), 'utf8').includes(`${runner[0]}(`)
139
+ ) {
140
+ process.stdout.write(
141
+ `vx is already set up here (${existing} and the projects' vx.config files); --force regenerates them from ${runner[1]}.\n\nnext: ${adoptionNext(root, ...runner, ['@vzn/vx'])}\n`,
142
+ )
143
+ return 0
144
+ }
145
+ return migrate(root, parsed)
146
+ }
147
+ if (parsed.mode !== undefined) {
148
+ throw new UserError(
149
+ `vx init: --${parsed.mode} answers a Turbo or Nx repo's question, and no turbo.json or nx.json is at ${relPosix(process.cwd(), root) || '.'}`,
150
+ )
106
151
  }
107
152
  const unmapped = namelessNotes(root, nameless)
108
153
  return applyMigration({
@@ -156,7 +201,35 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
156
201
  }
157
202
 
158
203
  /**
159
- * `vx init` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
204
+ * `vx init` in a Turbo or Nx repo runs `@vzn/vx-migrate`: the installed one,
205
+ * else this version's through `bun x`. It asks a terminal native or keep,
206
+ * installs what it writes, and runs `vx init --keep` for keep.
207
+ */
208
+ async function migrate(root: string, args: InitArgs): Promise<number> {
209
+ const flags = [
210
+ args.dry && '--dry',
211
+ args.force && '--force',
212
+ args.mjs && '--mjs',
213
+ args.mode === 'native' && '--native',
214
+ ].filter((f): f is string => typeof f === 'string')
215
+ return Bun.spawn(migrateCommand(root, flags), {
216
+ cwd: root,
217
+ stdio: ['inherit', 'inherit', 'inherit'],
218
+ // A compiled vx is a Bun runtime only when told so.
219
+ env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
220
+ }).exited
221
+ }
222
+
223
+ export function migrateCommand(root: string, flags: readonly string[]): string[] {
224
+ const installed = path.join(root, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
225
+ if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
226
+ // A source checkout is 0.0.0, which npm never had.
227
+ const version = VERSION === '0.0.0' ? 'latest' : VERSION
228
+ return [process.execPath, 'x', `@vzn/vx-migrate@${version}`, ...flags]
229
+ }
230
+
231
+ /**
232
+ * `vx init --keep` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
160
233
  * that runs the repo as it is, nothing else, and the one command that gets
161
234
  * from here to a run.
162
235
  */
@@ -206,7 +279,7 @@ async function adopt(
206
279
  ? ''
207
280
  : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
208
281
  process.stdout.write(
209
- `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until bunx @vzn/vx-migrate writes native config; nothing else written.\n` +
282
+ `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
210
283
  `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
211
284
  )
212
285
  return 0
@@ -320,9 +393,14 @@ const INSTALL: Record<PackageManager, string> = {
320
393
  const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
321
394
 
322
395
  /** Install what the workspace file imports, if missing, then run the repo's build. */
323
- export function adoptionNext(root: string, runner: 'turbo' | 'nx', source: string): string {
396
+ export function adoptionNext(
397
+ root: string,
398
+ runner: 'turbo' | 'nx',
399
+ source: string,
400
+ needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
401
+ ): string {
324
402
  const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
325
- const missing = ['@vzn/vx', '@vzn/vx-migrate'].filter(
403
+ const missing = needs.filter(
326
404
  (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
327
405
  )
328
406
  // A global vx (no runner started this one) runs the workspace's plugins as they are.
package/src/cli/last.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  outcomeWord,
18
18
  type RunSummaryRow,
19
19
  resolveRunId,
20
+ runFailures,
20
21
  shortRunId,
22
+ taskLog,
21
23
  } from '../orchestrator/index.js'
22
24
  import { formatElapsed, UserError } from '../util/index.js'
23
25
  import { findWorkspaceRoot } from '../workspace/index.js'
@@ -29,6 +31,8 @@ interface LastArgs {
29
31
  /** Only runs that failed: the latest one replayed, or the list narrowed. */
30
32
  failed?: boolean
31
33
  format: 'pretty' | 'json'
34
+ /** `--log <task>`: that task's output in the run, not the run's summary. */
35
+ log?: string
32
36
  /** `--cache-dir`: read the history a run with the same flag wrote. */
33
37
  cacheDir?: string
34
38
  error?: string
@@ -72,6 +76,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
72
76
  out.failed = true
73
77
  continue
74
78
  }
79
+ if (a === '--log' || a.startsWith('--log=')) {
80
+ const v = a === '--log' ? args[++i] : a.slice('--log='.length)
81
+ if (v === undefined || v === '' || v.startsWith('-')) {
82
+ return { ...out, error: `--log requires a task id, like app#build${seeHelp('last')}` }
83
+ }
84
+ out.log = v
85
+ continue
86
+ }
75
87
  if (a === '--format' || a.startsWith('--format=')) {
76
88
  const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'last')
77
89
  if (typeof fv === 'object') return { ...out, ...fv }
@@ -102,6 +114,12 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
102
114
  error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
103
115
  }
104
116
  }
117
+ if (out.log !== undefined && (out.list !== undefined || out.failed === true)) {
118
+ return {
119
+ ...out,
120
+ error: `--log reads one task's output and combines only with a run id${seeHelp('last')}`,
121
+ }
122
+ }
105
123
  if (out.runId !== undefined && out.failed === true) {
106
124
  return {
107
125
  ...out,
@@ -282,10 +300,11 @@ export function formatRunList(
282
300
 
283
301
  export async function lastCmd(args: readonly string[]): Promise<number> {
284
302
  const parsed = parseLastArgs(args)
285
- if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
303
+ if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`, 'VX_E_USAGE')
286
304
 
287
305
  const root = await findWorkspaceRoot(process.cwd())
288
- const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
306
+ const cacheDir = await cliCacheDir(root, parsed.cacheDir)
307
+ const cache = Cache.inspect(cacheDir)
289
308
  try {
290
309
  const db = cache.dbHandle()
291
310
  // Nothing recorded at all is its own answer: `--failed` said "no
@@ -293,6 +312,30 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
293
312
  // nothing, while `--list --failed` past green runs said there were none.
294
313
  const noRuns = (): boolean => listInvocations(db, { limit: 1 }).length === 0
295
314
 
315
+ if (parsed.log !== undefined) {
316
+ const runId =
317
+ parsed.runId === undefined
318
+ ? undefined
319
+ : (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
320
+ const log = taskLog(cacheDir, db, parsed.log, runId)
321
+ if (log === null) {
322
+ throw new UserError(
323
+ `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`} (a task id is project#task)`,
324
+ )
325
+ }
326
+ if (parsed.format === 'json') {
327
+ process.stdout.write(`${JSON.stringify(log)}\n`)
328
+ return 0
329
+ }
330
+ if (log.source === null) {
331
+ process.stderr.write(
332
+ `vx last: ${log.taskId} ended ${log.status}; vx keeps output only for failed and cached tasks\n`,
333
+ )
334
+ }
335
+ process.stdout.write(log.output)
336
+ return 0
337
+ }
338
+
296
339
  if (parsed.list !== undefined) {
297
340
  const invocations =
298
341
  parsed.failed === true
@@ -330,12 +373,20 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
330
373
  : parsed.failed === true
331
374
  ? 'vx last: no recorded run failed'
332
375
  : 'vx last: no recorded runs yet — run something first',
376
+ 'VX_E_NO_HISTORY',
333
377
  )
334
378
  }
335
379
  const detail = getRun(db, inv.runId)
336
380
 
337
381
  if (parsed.format === 'json') {
338
- process.stdout.write(`${JSON.stringify({ invocation: inv, tasks: detail?.tasks ?? [] })}\n`)
382
+ const failures = new Map(
383
+ (runFailures(cacheDir, db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]),
384
+ )
385
+ const tasks = (detail?.tasks ?? []).map((t) => {
386
+ const f = failures.get(`${t.project}#${t.task}`)
387
+ return f === undefined ? t : { ...t, output: f.output, locations: f.locations }
388
+ })
389
+ process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
339
390
  return 0
340
391
  }
341
392
 
@@ -1,6 +1,6 @@
1
1
  // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
2
2
 
3
- import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
3
+ import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
4
  import { formatElapsed, maskedCommand } from '../util/index.js'
5
5
 
6
6
  /**
@@ -52,6 +52,9 @@ export function formatPlanText(plan: RunPlan): string {
52
52
  if (taskDesc) {
53
53
  lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
54
54
  }
55
+ if (t.affected !== undefined) {
56
+ lines.push(` ${' '.repeat(idWidth)} affected: ${describeReason(t.affected)}`)
57
+ }
55
58
  if (skipped) noop++
56
59
  else if (t.cacheStatus === 'hit-local') local++
57
60
  else if (t.cacheStatus === 'hit-remote') remote++
@@ -104,6 +107,23 @@ export function formatPlanText(plan: RunPlan): string {
104
107
  return lines.join('\n') + '\n'
105
108
  }
106
109
 
110
+ /** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
111
+ function describeReason(r: AffectedReason): string {
112
+ const cause =
113
+ r.kind === 'named'
114
+ ? 'named on the command line'
115
+ : r.kind === 'selected'
116
+ ? 'its project is selected outright'
117
+ : r.kind === 'package'
118
+ ? `a ^ edge passes changed package ${r.project}`
119
+ : r.kind === 'input'
120
+ ? `${r.file} changed (an input)`
121
+ : r.file !== undefined
122
+ ? `${r.file} changed (in its project)`
123
+ : 'its project changed as a whole'
124
+ return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
125
+ }
126
+
107
127
  /** A TS config may build the description from `process.env` (L-11). */
108
128
  function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
109
129
  const { description, exec } = t.node.config
@@ -122,6 +142,7 @@ export interface PlanTaskJson {
122
142
  executor?: string
123
143
  download?: 'deferred'
124
144
  description?: string
145
+ affected?: AffectedReason
125
146
  }
126
147
 
127
148
  export function formatPlanJson(plan: RunPlan): string {
@@ -141,6 +162,7 @@ export function formatPlanJson(plan: RunPlan): string {
141
162
  ...(t.executor !== undefined ? { executor: t.executor } : {}),
142
163
  ...(t.download !== undefined ? { download: t.download } : {}),
143
164
  ...(description !== undefined ? { description } : {}),
165
+ ...(t.affected !== undefined ? { affected: t.affected } : {}),
144
166
  }
145
167
  }),
146
168
  ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
@@ -99,6 +99,9 @@ export async function resolvePluginCommand(
99
99
  workspaceRoot: ws.workspaceRoot,
100
100
  cacheDir: ws.cacheDir,
101
101
  concurrency: ws.concurrency,
102
+ vx: (await import('./upgrade.js')).isCompiledBinary()
103
+ ? [process.execPath]
104
+ : [process.execPath, Bun.main],
102
105
  warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
103
106
  },
104
107
  }
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
16
16
  commands: {
17
17
  plugin:
18
18
  "// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
19
- test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = { workspaceRoot: '/ws', cacheDir: '/ws/.vx/cache', warn() {}, concurrency: 1 }\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
19
+ test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
20
20
  },
21
21
  executor: {
22
22
  plugin:
package/src/cli/run.ts CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
4
  import { findWorkspaceRoot } from '../workspace/index.js'
5
5
  import { translateForeign } from './foreign-flags.js'
6
- import { flagHint, seeHelp } from './help.js'
6
+ import { flagHint, formatValue, refuse, seeHelp } from './help.js'
7
7
  import {
8
8
  planRun,
9
9
  formatRunReportMarkdown,
@@ -69,6 +69,8 @@ export interface RunArgs {
69
69
  forwardArgs: string[]
70
70
  verbosity: number
71
71
  dry: 'text' | 'json' | undefined
72
+ /** `--format json`: the run's result document on stdout, every other line on stderr. */
73
+ format: 'pretty' | 'json'
72
74
  graph: string | undefined
73
75
  summarize: string | undefined
74
76
  profile: string | undefined
@@ -132,6 +134,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
132
134
  forwardArgs: [],
133
135
  verbosity: 0,
134
136
  dry: undefined,
137
+ format: 'pretty',
135
138
  graph: undefined,
136
139
  summarize: undefined,
137
140
  profile: undefined,
@@ -342,6 +345,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
342
345
  return { ...out, error: `--dry must be text or json (got ${fmt})` }
343
346
  }
344
347
  out.dry = fmt
348
+ } else if (a === '--format' || a?.startsWith('--format=')) {
349
+ const f = formatValue(a === '--format' ? before[++i] : a.slice('--format='.length), verb)
350
+ if (typeof f !== 'string') return { ...out, error: f.error }
351
+ out.format = f
345
352
  } else if (a === '--graph') {
346
353
  out.graph = ''
347
354
  } else if (a?.startsWith('--graph=')) {
@@ -429,6 +436,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
429
436
  if (out.graph !== undefined && needsRun) {
430
437
  return { ...out, error: `--graph skips execution; ${runOnly}` }
431
438
  }
439
+ if (out.format === 'json') {
440
+ // Each of these prints something else on stdout, which the document owns.
441
+ if (out.dry !== undefined)
442
+ return { ...out, error: '--format json runs the tasks; --dry=json prints the plan' }
443
+ if (out.graph === '')
444
+ return {
445
+ ...out,
446
+ error: '--format json and --graph both print on stdout; give --graph a file',
447
+ }
448
+ if (out.report === 'markdown')
449
+ return {
450
+ ...out,
451
+ error: '--format json and --report=markdown both print on stdout; use --report-file',
452
+ }
453
+ }
432
454
  return out
433
455
  }
434
456
 
@@ -614,6 +636,7 @@ export async function resolveRunOptions(
614
636
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
615
637
  if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
616
638
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
639
+ if (parsed.format === 'json') opts.json = true
617
640
  if (parsed.profile !== undefined) opts.profile = parsed.profile
618
641
  if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
619
642
 
@@ -626,8 +649,7 @@ const TASKS_SHOWN = 12
626
649
  export async function runCmd(args: readonly string[]): Promise<number> {
627
650
  const parsed = parseRunArgs(args)
628
651
  if (parsed.error) {
629
- process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
630
- return 1
652
+ return refuse('run', args, `${parsed.error}${seeHelp('run')}`, 'VX_E_USAGE')
631
653
  }
632
654
 
633
655
  const cwd = process.cwd()
@@ -638,7 +660,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
638
660
  // anchored pkg#task; the rest of the pipeline treats it like any
639
661
  // explicit anchored positional.
640
662
  if (tasks.length === 0) {
641
- if (!process.stdin.isTTY) {
663
+ // A JSON answer is for a program, which cannot pick from a menu: an
664
+ // agent on a pty waited at the picker forever.
665
+ const forProgram = parsed.format === 'json' || parsed.dry === 'json'
666
+ if (!process.stdin.isTTY || forProgram) {
642
667
  const names = await taskNamesHere(cwd)
643
668
  const shown = (names ?? []).slice(0, TASKS_SHOWN)
644
669
  const more = (names?.length ?? 0) - shown.length
@@ -646,10 +671,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
646
671
  shown.length === 0
647
672
  ? 'vx run <task>, e.g. vx run build'
648
673
  : `tasks here: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
649
- process.stderr.write(
650
- `vx run: missing task name (stdin is not a TTY, so no picker; ${tasksHere})${seeHelp('run')}\n`,
674
+ return refuse(
675
+ 'run',
676
+ args,
677
+ `missing task name (${forProgram ? 'a JSON answer opens' : 'stdin is not a TTY, so'} no picker; ${tasksHere})${seeHelp('run')}`,
678
+ 'VX_E_USAGE',
651
679
  )
652
- return 1
653
680
  }
654
681
  // Every run needs git: refused here, not after the user has chosen.
655
682
  const refusal = gitRefusal(await findWorkspaceRoot(cwd))
@@ -664,17 +691,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
664
691
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
665
692
  // the menu listed every project and the anchored pick ran outside it.
666
693
  const scope = await scopeFilters(parsed, cwd)
667
- if ('error' in scope) {
668
- process.stderr.write(`vx run: ${scope.error}\n`)
669
- return 1
670
- }
694
+ if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
671
695
  let only: ReadonlySet<string> | undefined
672
696
  if (scope.filterStrings.length > 0) {
673
697
  const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
674
- if ('error' in selected) {
675
- process.stderr.write(`vx run: ${selected.error}\n`)
676
- return 1
677
- }
698
+ if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
678
699
  if ('empty' in selected) {
679
700
  process.stderr.write(`vx run: ${selected.empty}\n`)
680
701
  return 0
@@ -689,10 +710,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
689
710
  }
690
711
 
691
712
  const resolved = await resolveRunOptions(parsed, cwd, tasks)
692
- if ('error' in resolved) {
693
- process.stderr.write(`vx run: ${resolved.error}\n`)
694
- return 1
695
- }
713
+ if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
696
714
  if ('nothingSelected' in resolved) {
697
715
  process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
698
716
  return 0
@@ -783,8 +801,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
783
801
  // is no whole-run delegation seam to consult.
784
802
  const summary = await runOrchestrator(opts)
785
803
  if (summary.refused !== undefined) {
786
- process.stderr.write(`vx run: ${summary.refused}\n`)
787
- return 1
804
+ const unknown = summary.refused.startsWith('no projects declare')
805
+ return refuse('run', args, summary.refused, unknown ? 'VX_E_UNKNOWN_TASK' : 'VX_E_REFUSED')
788
806
  }
789
807
  if (wantsReport) {
790
808
  const md =
@@ -816,5 +834,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
816
834
  }
817
835
  }
818
836
  }
837
+ if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
819
838
  return summary.ok ? 0 : 1
820
839
  }
package/src/cli/show.ts CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
13
- import { flagHint, formatValue, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
15
  import { affectedFilterFor, resolveFilters } from './select.js'
16
16
  import {
@@ -82,10 +82,7 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
82
82
 
83
83
  export async function showCmd(args: readonly string[]): Promise<number> {
84
84
  const parsed = parseShowArgs(args)
85
- if (parsed.error) {
86
- process.stderr.write(`vx show: ${parsed.error}\n`)
87
- return 1
88
- }
85
+ if (parsed.error) return refuse('show', args, parsed.error, 'VX_E_USAGE')
89
86
  const reads: LoadReads = new Map()
90
87
  const root = await findWorkspaceRoot(process.cwd(), reads)
91
88
  const metas = await discoverCliProjects(await loadWorkspace(root, reads))
@@ -98,6 +95,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
98
95
  if (rootMeta === undefined) {
99
96
  throw new UserError(
100
97
  `vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
98
+ 'VX_E_USAGE',
101
99
  )
102
100
  }
103
101
  parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
@@ -114,12 +112,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
114
112
  const list = projectName === '' ? 'vx show' : `vx show ${projectName}`
115
113
  throw new UserError(
116
114
  `vx show: missing task name after '#' in "${parsed.target}" (${list} lists the tasks)`,
115
+ 'VX_E_USAGE',
117
116
  )
118
117
  }
119
118
  if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
120
119
  if (!byName.has(projectName)) {
121
120
  throw new UserError(
122
121
  `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
122
+ 'VX_E_UNKNOWN_PROJECT',
123
123
  )
124
124
  }
125
125
  }
@@ -138,18 +138,19 @@ export async function showCmd(args: readonly string[]): Promise<number> {
138
138
  if (scope !== 'all') {
139
139
  throw new UserError(
140
140
  'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
141
+ 'VX_E_USAGE',
141
142
  )
142
143
  }
143
144
  const raw = [...parsed.filters]
144
145
  let affected: string | undefined
145
146
  if (parsed.affected !== undefined) {
146
147
  const f = await affectedFilterFor(process.cwd(), parsed.affected)
147
- if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`)
148
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
148
149
  affected = f
149
150
  raw.unshift(f)
150
151
  }
151
152
  const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
152
- if ('error' in r) throw new UserError(`vx show: ${r.error}`)
153
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
153
154
  if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
154
155
  selected = new Set('empty' in r ? [] : r.names)
155
156
  if ('names' in r && r.affected !== undefined)
@@ -181,6 +182,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
181
182
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
182
183
  throw new UserError(
183
184
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
185
+ 'VX_E_UNKNOWN_TASK',
184
186
  )
185
187
  }
186
188
  let shown = declaring.filter((p) => inSelection(p.name))
@@ -224,6 +226,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
224
226
  if (task === undefined) {
225
227
  throw new UserError(
226
228
  `vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`, 'its tasks')}`,
229
+ 'VX_E_UNKNOWN_TASK',
227
230
  )
228
231
  }
229
232
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
@@ -38,7 +38,7 @@ export function isBunfsPath(p: string): boolean {
38
38
  * upgrade` refused with "running from source". `Bun.main` stays the
39
39
  * bunfs path under every compile-flag combination.
40
40
  */
41
- function isCompiledBinary(): boolean {
41
+ export function isCompiledBinary(): boolean {
42
42
  return (
43
43
  isBunfsPath(Bun.main) || isBunfsPath(process.argv[1] ?? '') || isBunfsPath(import.meta.path)
44
44
  )
package/src/cli/watch.ts CHANGED
@@ -78,8 +78,13 @@ export function watchRefusal(parsed: RunArgs): string | null {
78
78
  // All three format ONE run's result and are consumed by `runCmd` alone, so
79
79
  // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
80
80
  // asks for the output watch already gives.
81
- if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
82
- return 'vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)'
81
+ if (
82
+ parsed.report !== undefined ||
83
+ parsed.reportFile !== undefined ||
84
+ parsed.verbosity > 0 ||
85
+ parsed.format !== 'pretty'
86
+ ) {
87
+ return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
83
88
  }
84
89
  return null
85
90
  }