@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
package/src/cli/init.ts CHANGED
@@ -1,16 +1,19 @@
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 [--format json]] [--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
 
9
10
  import { existsSync, readFileSync } from 'node:fs'
10
11
  import { mkdir, unlink } from 'node:fs/promises'
11
12
  import path from 'node:path'
12
- import { flagHint, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, refuse, 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,10 @@ 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'
37
+ /** `--format json` (with `--dry`): the plan as one JSON document. */
38
+ json?: boolean
32
39
  error?: string
33
40
  }
34
41
 
@@ -46,13 +53,29 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
46
53
  }
47
54
  }
48
55
  out.plugin = seam
56
+ } else if (a === '--format' || a.startsWith('--format=')) {
57
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'init')
58
+ if (typeof fv === 'object') return { ...out, error: fv.error }
59
+ out.json = fv === 'json'
49
60
  } else if (a === '--dry') out.dry = true
50
61
  else if (a === '--force') out.force = true
51
62
  else if (a === '--mjs') out.mjs = true
52
- else if (a.startsWith('-'))
63
+ else if (a === '--native' || a === '--keep') {
64
+ const mode = a === '--native' ? 'native' : 'keep'
65
+ if (out.mode !== undefined && out.mode !== mode) {
66
+ return {
67
+ ...out,
68
+ error: `--native and --keep are two answers to one question; pass one${seeHelp('init')}`,
69
+ }
70
+ }
71
+ out.mode = mode
72
+ } else if (a.startsWith('-'))
53
73
  return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
54
74
  else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
55
75
  }
76
+ if (out.plugin !== undefined && out.mode !== undefined) {
77
+ return { ...out, error: `--${out.mode} does not combine with --plugin${seeHelp('init')}` }
78
+ }
56
79
  if (out.plugin !== undefined && out.mjs) {
57
80
  return {
58
81
  ...out,
@@ -64,16 +87,22 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
64
87
 
65
88
  export async function initCmd(args: readonly string[]): Promise<number> {
66
89
  const parsed = parseInitArgs(args)
67
- if (parsed.error) {
68
- process.stderr.write(`vx init: ${parsed.error}\n`)
69
- return 1
90
+ if (parsed.error) return refuse('init', args, parsed.error, 'VX_E_USAGE')
91
+ // In the verb, not the parser: completion asks the parser one flag at a time.
92
+ if (parsed.json === true && (!parsed.dry || parsed.plugin !== undefined)) {
93
+ return refuse(
94
+ 'init',
95
+ args,
96
+ `--format json prints the workspace plan, so it needs --dry and no --plugin${seeHelp('init')}`,
97
+ 'VX_E_USAGE',
98
+ )
70
99
  }
71
100
  if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
72
101
  const reads: LoadReads = new Map()
73
102
  // "a workspace from nowhere" still needs a package.json to start from,
74
103
  // and the lookup's own refusal named no next step.
75
104
  const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
76
- if (isUserError(err) && err.message.startsWith('Could not find a workspace root')) {
105
+ if (isUserError(err) && err.code === 'VX_E_NO_WORKSPACE') {
77
106
  throw new UserError(
78
107
  '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
108
  )
@@ -95,14 +124,42 @@ export async function initCmd(args: readonly string[]): Promise<number> {
95
124
  break
96
125
  }
97
126
  }
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)
127
+ // A Turbo or Nx repo already says its tasks: writing a config per package
128
+ // from the scripts dropped every edge turbo.json declares (the
129
+ // first-five-minutes walk, 2026-09-28). vx-migrate maps the runner's own
130
+ // config, so `vx init` is the one command (M, 2026-10-08); its `--keep`
131
+ // comes back here for the workspace file alone.
132
+ const runner: ['turbo' | 'nx', string] | undefined =
133
+ turbo !== undefined
134
+ ? ['turbo', turbo]
135
+ : (await Bun.file(path.join(root, 'nx.json')).exists())
136
+ ? ['nx', 'nx.json']
137
+ : undefined
138
+ if (runner !== undefined) {
139
+ if (parsed.mode === 'keep') return adopt(root, ...runner, parsed)
140
+ // A second `vx init` after the native migration refused to overwrite
141
+ // the configs it wrote, exit 1, after a package fetch: the first-run
142
+ // walk (2026-10-09). Set up is an answer, with the next step.
143
+ const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
144
+ if (
145
+ existing !== undefined &&
146
+ !parsed.force &&
147
+ !parsed.dry &&
148
+ parsed.mode === undefined &&
149
+ metas.some((m) => m.configPath !== null) &&
150
+ !readFileSync(path.join(root, existing), 'utf8').includes(`${runner[0]}(`)
151
+ ) {
152
+ process.stdout.write(
153
+ `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`,
154
+ )
155
+ return 0
156
+ }
157
+ return migrate(root, parsed)
158
+ }
159
+ if (parsed.mode !== undefined) {
160
+ throw new UserError(
161
+ `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) || '.'}`,
162
+ )
106
163
  }
107
164
  const unmapped = namelessNotes(root, nameless)
108
165
  return applyMigration({
@@ -118,6 +175,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
118
175
  notes: [...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
119
176
  unmapped: unmapped.length > 0,
120
177
  dry: parsed.dry,
178
+ json: parsed.json === true,
121
179
  force: parsed.force,
122
180
  init: true,
123
181
  format: parsed.mjs ? 'mjs' : 'ts',
@@ -156,7 +214,36 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
156
214
  }
157
215
 
158
216
  /**
159
- * `vx init` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
217
+ * `vx init` in a Turbo or Nx repo runs `@vzn/vx-migrate`: the installed one,
218
+ * else this version's through `bun x`. It asks a terminal native or keep,
219
+ * installs what it writes, and runs `vx init --keep` for keep.
220
+ */
221
+ async function migrate(root: string, args: InitArgs): Promise<number> {
222
+ const flags = [
223
+ args.dry && '--dry',
224
+ args.force && '--force',
225
+ args.mjs && '--mjs',
226
+ args.mode === 'native' && '--native',
227
+ args.json === true && '--format=json',
228
+ ].filter((f): f is string => typeof f === 'string')
229
+ return Bun.spawn(migrateCommand(root, flags), {
230
+ cwd: root,
231
+ stdio: ['inherit', 'inherit', 'inherit'],
232
+ // A compiled vx is a Bun runtime only when told so.
233
+ env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
234
+ }).exited
235
+ }
236
+
237
+ export function migrateCommand(root: string, flags: readonly string[]): string[] {
238
+ const installed = path.join(root, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
239
+ if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
240
+ // A source checkout is 0.0.0, which npm never had.
241
+ const version = VERSION === '0.0.0' ? 'latest' : VERSION
242
+ return [process.execPath, 'x', `@vzn/vx-migrate@${version}`, ...flags]
243
+ }
244
+
245
+ /**
246
+ * `vx init --keep` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
160
247
  * that runs the repo as it is, nothing else, and the one command that gets
161
248
  * from here to a run.
162
249
  */
@@ -184,7 +271,7 @@ async function adopt(
184
271
  )
185
272
  }
186
273
  } else if (args.dry) {
187
- process.stdout.write(`── ${name} ──\n${text}\n`)
274
+ if (args.json !== true) process.stdout.write(`── ${name} ──\n${text}\n`)
188
275
  } else {
189
276
  if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
190
277
  await Bun.write(path.join(root, name), text)
@@ -205,8 +292,23 @@ async function adopt(
205
292
  : kept
206
293
  ? ''
207
294
  : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
295
+ if (args.json === true) {
296
+ process.stdout.write(
297
+ `${JSON.stringify({
298
+ dry: true,
299
+ source,
300
+ files: kept ? [] : [{ path: name, contents: text }],
301
+ kept: kept ? [existing] : [],
302
+ replaced: !kept && existing !== undefined && existing !== name ? [existing] : [],
303
+ todos: [],
304
+ notes: cacheLine === '' ? [] : [cacheLine.trimEnd()],
305
+ next: adoptionNext(root, runner, source),
306
+ })}\n`,
307
+ )
308
+ return 0
309
+ }
208
310
  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` +
311
+ `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
210
312
  `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
211
313
  )
212
314
  return 0
@@ -320,9 +422,14 @@ const INSTALL: Record<PackageManager, string> = {
320
422
  const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
321
423
 
322
424
  /** 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 {
425
+ export function adoptionNext(
426
+ root: string,
427
+ runner: 'turbo' | 'nx',
428
+ source: string,
429
+ needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
430
+ ): string {
324
431
  const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
325
- const missing = ['@vzn/vx', '@vzn/vx-migrate'].filter(
432
+ const missing = needs.filter(
326
433
  (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
327
434
  )
328
435
  // 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
 
package/src/cli/lock.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
- import { flagHint, refusedWord, seeHelp } from './help.js'
6
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
7
  import { relPosix, secretMask, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
@@ -22,16 +22,22 @@ import { discoverCliProjects } from './workspace-config.js'
22
22
 
23
23
  interface LockArgs {
24
24
  check: boolean
25
+ format: 'pretty' | 'json'
25
26
  error?: string
26
27
  }
27
28
 
28
29
  export function parseLockArgs(args: readonly string[]): LockArgs {
29
- const out: LockArgs = { check: false }
30
- for (const a of args) {
30
+ const out: LockArgs = { check: false, format: 'pretty' }
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i]!
31
33
  if (a === '--check') out.check = true
32
- else
34
+ else if (a === '--format' || a.startsWith('--format=')) {
35
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'lock')
36
+ if (typeof fv === 'object') return { ...out, ...fv }
37
+ out.format = fv
38
+ } else
33
39
  return {
34
- check: false,
40
+ ...out,
35
41
  error: `${refusedWord(a)}: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
36
42
  }
37
43
  }
@@ -42,10 +48,15 @@ type ConfiguredMeta = ProjectMeta & { configPath: string }
42
48
 
43
49
  export async function lockCmd(args: readonly string[]): Promise<number> {
44
50
  const parsed = parseLockArgs(args)
45
- if (parsed.error) {
46
- process.stderr.write(`vx lock: ${parsed.error}\n`)
47
- return 1
48
- }
51
+ if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
52
+ // Writing the lock prints one line; only the audit has an answer to shape.
53
+ if (parsed.format === 'json' && !parsed.check)
54
+ return refuse(
55
+ 'lock',
56
+ args,
57
+ `--format json answers --check only${seeHelp('lock')}`,
58
+ 'VX_E_USAGE',
59
+ )
49
60
  const reads: LoadReads = new Map()
50
61
  const root = await findWorkspaceRoot(process.cwd(), reads)
51
62
  const workspace = await loadWorkspace(root, reads)
@@ -62,7 +73,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
62
73
  // count above leaves out — `locked 0 project configs` on a turbo() repo
63
74
  // read like an audit of something (2026-09-16).
64
75
  const bare = all.length - metas.length
65
- if (parsed.check) return await checkLock(root, metas, bare)
76
+ if (parsed.check) return await checkLock(root, metas, bare, parsed.format === 'json')
66
77
  return await writeLock(root, metas, bare)
67
78
  }
68
79
 
@@ -154,13 +165,29 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
154
165
  * catches eval-time env-var drift that file hashes cannot see —
155
166
  * file bytes unchanged, resolved value changed.
156
167
  */
157
- async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
168
+ async function checkLock(
169
+ root: string,
170
+ metas: ConfiguredMeta[],
171
+ bare: number,
172
+ json: boolean,
173
+ ): Promise<number> {
174
+ const answer = (problems: string[]): number => {
175
+ if (json) {
176
+ const out = {
177
+ upToDate: problems.length === 0,
178
+ audited: metas.length,
179
+ notAudited: bare,
180
+ problems,
181
+ }
182
+ process.stdout.write(`${JSON.stringify(out)}\n`)
183
+ }
184
+ return problems.length === 0 ? 0 : 1
185
+ }
158
186
  const lock = await readLockfile(root)
159
187
  if (!lock) {
160
- process.stderr.write(
161
- `vx lock --check: no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first\n`,
162
- )
163
- return 1
188
+ const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
189
+ if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
190
+ return answer([problem])
164
191
  }
165
192
  const results = await Promise.all(
166
193
  metas.map(async (m): Promise<string | null> => {
@@ -200,6 +227,7 @@ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): P
200
227
  )
201
228
  }
202
229
  }
230
+ if (json) return answer(failures)
203
231
  if (failures.length > 0) {
204
232
  for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
205
233
  return 1
@@ -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: