@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/cache.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { Cache } from '../cache/index.js'
1
+ import { Cache, noteSchemaReset } from '../cache/index.js'
2
+ import { seeHelp } from './help.js'
2
3
  import { parseDecimalInt, parseSize } from '../util/index.js'
3
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
4
+ import { findWorkspaceRoot } from '../workspace/index.js'
5
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
4
6
  import { formatBytes } from './format.js'
5
7
 
6
8
  // parseSize moved to `util` (the orchestrator's resource resolver needs it
@@ -16,7 +18,7 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
16
18
  process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
17
19
  return 1
18
20
  default:
19
- process.stderr.write(`vx cache: unknown subcommand: ${sub}\n`)
21
+ process.stderr.write(`vx cache: unknown subcommand: ${sub}${seeHelp('cache')}\n`)
20
22
  return 1
21
23
  }
22
24
  }
@@ -24,6 +26,9 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
24
26
  interface PruneArgs {
25
27
  olderThanMs?: number
26
28
  maxBytes?: number
29
+ dryRun?: boolean
30
+ /** `--cache-dir`: prune the cache a run with the same flag uses. */
31
+ cacheDir?: string
27
32
  error?: string
28
33
  }
29
34
 
@@ -60,9 +65,25 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
60
65
  error: `--max-size 0 would evict every entry — delete the cache directory instead`,
61
66
  }
62
67
  }
68
+ // A bare number is bytes to `parseSize` (a computed size needs that),
69
+ // but nobody caps a cache at 10 bytes: `--max-size 10`
70
+ // meant 10G or 10M and would evict everything, the same class of
71
+ // destructive typo the zero bound above refuses. An explicit `10B`
72
+ // still passes.
73
+ if (/^\d+$/.test(v)) {
74
+ return {
75
+ error: `--max-size ${v} would read as ${v} bytes and evict nearly everything — give a unit (e.g. ${v}M, ${v}G)`,
76
+ }
77
+ }
63
78
  out.maxBytes = bytes
79
+ } else if (a === '--dry-run') {
80
+ out.dryRun = true
64
81
  } else {
65
- return { error: `unknown argument: ${a}` }
82
+ const cd = parseCacheDirFlag(args, i)
83
+ if (cd === null) return { error: `unknown argument: ${a}${seeHelp('cache')}` }
84
+ if ('error' in cd) return { error: cd.error }
85
+ out.cacheDir = cd.cacheDir
86
+ i = cd.next
66
87
  }
67
88
  }
68
89
  if (out.olderThanMs === undefined && out.maxBytes === undefined) {
@@ -85,18 +106,25 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
85
106
  process.stderr.write(`vx cache prune: ${(err as Error).message}\n`)
86
107
  return 1
87
108
  }
88
- // Honor `defineWorkspace({ cacheDir: '...' })` — `vx run` and
89
- // `vx cache prune` must operate on the same directory or prune
90
- // silently no-ops against the wrong path.
91
- const workspaceConfig = await loadWorkspaceConfig(root)
92
- const cache = new Cache(resolveCacheDir(root, workspaceConfig))
109
+ // Honor `--cache-dir`, `defineWorkspace({ cacheDir: '...' })` and a
110
+ // `config` plugin's edit of it — `vx run` and `vx cache prune` must
111
+ // operate on the same directory or prune silently no-ops against the
112
+ // wrong path.
113
+ const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
114
+ noteSchemaReset(cache, warnToStderr)
93
115
  try {
94
- const opts: { olderThanMs?: number; maxBytes?: number } = {}
116
+ const opts: { olderThanMs?: number; maxBytes?: number; dryRun?: boolean } = {}
95
117
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
96
118
  if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
119
+ if (parsed.dryRun) opts.dryRun = true
97
120
  const result = await cache.prune(opts)
121
+ const dry = parsed.dryRun === true
122
+ const orphans =
123
+ result.orphans > 0
124
+ ? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
125
+ : ''
98
126
  process.stdout.write(
99
- `Pruned ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)} freed)\n`,
127
+ `${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
100
128
  )
101
129
  } finally {
102
130
  cache.close()
@@ -0,0 +1,104 @@
1
+ // `vx completions <shell>` — a bash / zsh / fish script over the verb table
2
+ // and each verb's help cut, so the verbs and every flag of each complete
3
+ // from the one text `vx <verb> --help` prints. Plugin verbs of the
4
+ // workspace around the cwd are included at generation time; task and
5
+ // project names are not (a completion that evaluates configs on every
6
+ // Tab is the wrong price).
7
+
8
+ import { CORE_VERBS, seeHelp, verbHelpText } from './help.js'
9
+
10
+ export type CompletionShell = 'bash' | 'zsh' | 'fish'
11
+ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
12
+
13
+ /** Every `--flag` the verb's help cut names, plus `--help`; deduped, in order of appearance. */
14
+ export function verbFlags(verb: string): string[] {
15
+ const flags = new Set<string>(['--help'])
16
+ for (const f of verbHelpText(verb).match(/--[a-zA-Z][a-zA-Z-]*/g) ?? []) flags.add(f)
17
+ return [...flags]
18
+ }
19
+
20
+ /** The words that follow `vx <verb>` besides flags: subcommands and fixed choices. */
21
+ const VERB_WORDS: Readonly<Record<string, readonly string[]>> = {
22
+ cache: ['prune'],
23
+ completions: [...SHELLS],
24
+ }
25
+
26
+ export function completionScript(shell: CompletionShell, verbs: readonly string[]): string {
27
+ const table = verbs.map((v) => ({
28
+ verb: v,
29
+ // A plugin verb owns its own arguments; only `--help` is known here.
30
+ words: [
31
+ ...(VERB_WORDS[v] ?? []),
32
+ ...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
33
+ ],
34
+ }))
35
+ switch (shell) {
36
+ case 'bash':
37
+ return [
38
+ '# vx completions — generated by `vx completions bash`',
39
+ '_vx() {',
40
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
41
+ ' local verb="${COMP_WORDS[1]}"',
42
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
43
+ ` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
44
+ ' return',
45
+ ' fi',
46
+ ' case "$verb" in',
47
+ ...table.map(
48
+ (t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
49
+ ),
50
+ ' *) COMPREPLY=() ;;',
51
+ ' esac',
52
+ '}',
53
+ 'complete -F _vx vx',
54
+ '',
55
+ ].join('\n')
56
+ case 'zsh':
57
+ return [
58
+ '#compdef vx',
59
+ '# vx completions — generated by `vx completions zsh`',
60
+ '_vx() {',
61
+ ' if (( CURRENT == 2 )); then',
62
+ ` compadd -- ${verbs.join(' ')}`,
63
+ ' return',
64
+ ' fi',
65
+ ' case "${words[2]}" in',
66
+ ...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
67
+ ' esac',
68
+ '}',
69
+ 'compdef _vx vx',
70
+ '',
71
+ ].join('\n')
72
+ case 'fish':
73
+ return [
74
+ '# vx completions — generated by `vx completions fish`',
75
+ 'complete -c vx -f',
76
+ ...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
77
+ ...table.flatMap((t) =>
78
+ t.words.map((w) =>
79
+ w.startsWith('--')
80
+ ? `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -l ${w.slice(2)}`
81
+ : `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -a ${w}`,
82
+ ),
83
+ ),
84
+ '',
85
+ ].join('\n')
86
+ }
87
+ }
88
+
89
+ export async function completionsCmd(
90
+ args: readonly string[],
91
+ pluginVerbs: readonly string[],
92
+ ): Promise<number> {
93
+ const shell = args[0]
94
+ if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
95
+ process.stderr.write(
96
+ `vx completions: expected one shell — bash, zsh or fish${shell !== undefined && args.length === 1 ? ` (got ${shell})` : ''}${seeHelp('completions')}\n`,
97
+ )
98
+ return 1
99
+ }
100
+ const verbs = [...CORE_VERBS.filter((v) => v !== 'help' && v !== 'version'), 'help', 'version']
101
+ for (const v of pluginVerbs) if (!verbs.includes(v)) verbs.push(v)
102
+ process.stdout.write(completionScript(shell as CompletionShell, verbs))
103
+ return 0
104
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * One core per process. A plugin package, a `vx.workspace.ts`, a
3
+ * `vx.config.ts` all `import … from '@vzn/vx'`, and that specifier
4
+ * resolves through node_modules — to a SECOND copy of core when the host
5
+ * is the compiled binary (whose own copy lives under the bunfs root) or
6
+ * an install with its own nested `@vzn/vx`. The second copy is core's
7
+ * whole source transpiled again, per process: ~20 ms per plugin package
8
+ * in the binary (measured 2026-09-10, `@vzn/vx-otel`: 20–25 ms → 2–3 ms),
9
+ * and two module states — two `definePlugin` memos, two of everything
10
+ * keyed by identity rather than by registry symbol.
11
+ *
12
+ * The alias is a Bun virtual module for the exact specifier `@vzn/vx`,
13
+ * served from the host's own façade, loaded lazily on the first import
14
+ * so a verb that never loads a plugin never loads the façade. The
15
+ * source form (`bun src/bin.ts`) resolves the specifier to the same file
16
+ * anyway; the alias makes that a guarantee rather than a layout fact.
17
+ */
18
+ export function registerCoreAlias(load: () => Promise<Record<string, unknown>>): void {
19
+ Bun.plugin({
20
+ name: 'vx-core-alias',
21
+ setup(build) {
22
+ build.module('@vzn/vx', async () => ({ exports: await load(), loader: 'object' }))
23
+ },
24
+ })
25
+ }
package/src/cli/help.ts CHANGED
@@ -1,5 +1,32 @@
1
- export function printHelp(pluginCommands: readonly string[] = []): void {
2
- process.stdout.write(helpText(pluginCommands))
1
+ export function printHelp(pluginCommands: readonly string[] = [], verb?: string): void {
2
+ process.stdout.write(verb === undefined ? helpText(pluginCommands) : verbHelpText(verb))
3
+ }
4
+
5
+ /**
6
+ * `vx <verb> --help`: the reference cut to one verb — the title, the Usage
7
+ * lines that name it, and every section that is `(for <verb>)` or lists a
8
+ * `vx <verb>` form — read from the same text, so nothing can drift. A verb
9
+ * the reference does not know gets the whole thing.
10
+ */
11
+ export function verbHelpText(verb: string): string {
12
+ const blocks = helpText().split('\n\n')
13
+ const title = blocks[0]!
14
+ const names = (line: string): boolean =>
15
+ new RegExp(`^\\s*vx ${verb}( |$)`).test(line) ||
16
+ line.includes(`(for ${verb})`) ||
17
+ line.includes(`(for ${verb} `)
18
+ const kept: string[] = []
19
+ for (const block of blocks.slice(1)) {
20
+ const lines = block.split('\n')
21
+ if (lines[0] === 'Usage:') {
22
+ const own = lines.slice(1).filter(names)
23
+ if (own.length > 0) kept.push(['Usage:', ...own].join('\n'))
24
+ continue
25
+ }
26
+ if (lines.some(names)) kept.push(block)
27
+ }
28
+ if (kept.length === 0) return helpText()
29
+ return [title, ...kept, 'Full reference: vx help', ''].join('\n\n')
3
30
  }
4
31
 
5
32
  export function helpText(pluginCommands: readonly string[] = []): string {
@@ -9,16 +36,15 @@ export function helpText(pluginCommands: readonly string[] = []): string {
9
36
  'Usage:',
10
37
  ' vx run [OPTIONS] [TASK | PKG#TASK] [-- forwarded-args...]',
11
38
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
12
- ' vx cache prune [--older-than <duration>] [--max-size <bytes>]',
39
+ ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
13
40
  ' vx lock [--check]',
14
- ' vx init [--dry] [--force]',
15
- ' vx migrate [--from turbo|nx|scripts] [--dry] [--force]',
41
+ ' vx init [--dry] [--force] [--mjs]',
16
42
  ' vx upgrade [tag]',
17
- ' vx show [PROJECT[#TASK]] [--format pretty|json]',
18
- ' vx info',
19
- ' vx why [TASK | PKG#TASK] [--run <runId>] [--format pretty|json]',
20
- ' vx last [RUNID] [--list[=N]] [--format pretty|json]',
21
- ' vx prune PROJECT [--out-dir <dir>] [--docker]',
43
+ ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
44
+ ' vx info [--format pretty|json] [--cache-dir <path>]',
45
+ ' vx why [TASK | PKG#TASK] [--run <runId>] [--format pretty|json] [--cache-dir <path>]',
46
+ ' vx last [RUNID] [--list[=N]] [--format pretty|json] [--cache-dir <path>]',
47
+ ' vx completions bash|zsh|fish',
22
48
  ' vx help',
23
49
  ' vx version',
24
50
  '',
@@ -33,8 +59,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
33
59
  " pkg#task Run a specific project's task directly.",
34
60
  '',
35
61
  'Execution (for run):',
36
- ' --concurrency <n> Max parallel tasks (default: CPU count).',
37
- ' --excludeDependencies[=names] Skip dependsOn edges. No value = all; comma list = specific names.',
62
+ ' --concurrency <n> Max parallel tasks (default: the cores this process may use); `50%` = half of them.',
63
+ ' --exclude-dependencies[=names] Skip dependsOn edges. No value = all; comma list = specific names.',
38
64
  ' --no-cache Disable caching entirely (no reads, no writes, outputs left alone).',
39
65
  ' --force Re-execute everything (skip reads) but still refresh the cache (writes on).',
40
66
  ' --cache <spec> Per-layer read/write control. Comma list of <layer>:<flags>',
@@ -50,23 +76,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
50
76
  ' --timeout <ms> Default per-task timeout for tasks without their own exec.timeout',
51
77
  ' (above VX_TASK_TIMEOUT env + workspace timeout). Kills + fails',
52
78
  ' a runaway task. Per-task exec.timeout always wins.',
53
- ' --memory <size> Memory budget (e.g. 8GB) that exec.resources.memory reservations',
54
- ' pack against. Default: total system RAM — pass this in',
55
- ' cgroup-limited containers (the default reads the HOST RAM).',
56
- ' Reservations are per-task config (exec.resources), not flags.',
57
- ' --verify[=<what>] Prove cache correctness. determinism (default): re-run each',
58
- ' executed cacheable task + content-compare outputs. inputs: sandbox',
59
- ' with the declared-input baseline + flag reads of undeclared inputs.',
60
- ' fingerprint: ship output-tree fingerprints for the cross-machine',
61
- ' diff on a connected serve (~1× exec — no re-run). all: everything.',
62
- ' An unsafe task fails the run with the exact paths.',
63
- ' ~2× exec (determinism); CI-shaped. inputs/all need the OS sandbox.',
64
- ' --verify-allow <pkg#task,…> Exempt known-nondeterministic tasks from failing --verify.',
65
79
  ' --frozen Load configs from vx-lock.json (CI; pair with vx lock --check).',
66
80
  ' --output-logs <mode> full | errors-only | hash-only | none — overrides the flow default',
67
81
  ' --download <mode> all (default) | toplevel | none — where remote outputs land',
68
82
  ' (focused without --all/--filter/--affected, broad with, full in CI).',
69
- ' --verbosity <n> 0=quiet, 1=verbose summary, 2+=debug (reserved).',
83
+ ' --verbosity <n> 0 (default) or 1+: print a per-task summary table after the run.',
70
84
  '',
71
85
  'Planning (for run — skips execution):',
72
86
  ' --dry[=text|json] Print the task graph + cache hit/miss prediction.',
@@ -97,6 +111,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
97
111
  'Cache management:',
98
112
  ' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
99
113
  ' vx cache prune --max-size 1G Keep total cache under 1 GB (LRU eviction).',
114
+ ' --dry-run Say what the policy would reap; delete nothing.',
115
+ ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
100
116
  '',
101
117
  ' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
102
118
  '',
@@ -105,20 +121,18 @@ export function helpText(pluginCommands: readonly string[] = []): string {
105
121
  " vx show <project> Print the project's LIVE resolved config (fresh",
106
122
  ' evaluation, not the lock), one block per task.',
107
123
  " vx show <pkg>#<task> Print a single task's resolved config.",
124
+ ' vx show <task> That task in every project declaring it.',
108
125
  ' --format <fmt> pretty (default) | json.',
109
126
  ' vx info Workspace doctor: vx/bun/git versions, project +',
110
127
  ' task counts, cache dir/entries/size, recent runs,',
111
128
  ' lock status.',
129
+ ' --format <fmt> pretty (default) | json.',
112
130
  ' vx stats Deprecated: alias of vx info.',
113
131
  " vx why <task> Why did this task re-run? Compares the task's latest",
114
132
  ' run (or --run <id>) against its previous run: names the',
115
133
  ' exact changed cache-key components (files / env / runtime',
116
134
  ' / upstream) from the persisted input fingerprints.',
117
135
  ' --format <fmt> pretty (default) | json.',
118
- ' vx prune <project> Emit a self-contained workspace SUBSET — the project +',
119
- ' its transitive workspace deps, root manifests, and the',
120
- ' (unpruned) lockfile — for Docker builds. --docker splits',
121
- ' json/ (manifests, the cacheable install layer) from full/.',
122
136
  " vx last [runId] Replay a recorded run's summary from the local history —",
123
137
  ' header (command, when, branch, counts) plus the per-task',
124
138
  ' table, failures first. No re-execution, no cache probe.',
@@ -127,14 +141,17 @@ export function helpText(pluginCommands: readonly string[] = []): string {
127
141
  '',
128
142
  'Migration:',
129
143
  ' vx init Generate vx.workspace.ts + one vx.config.ts per package',
130
- ' from package.json scripts (= vx migrate --from scripts).',
131
- ' vx migrate Generate vx.config.ts per package from turbo.json or an',
132
- ' Nx project graph (.nx/workspace-data/project-graph.json);',
133
- ' with neither present, from package.json scripts.',
134
- ' Unmappable settings become TODO(vx-migrate) comments;',
135
- ' turbo global fields become a shared vx-preset.ts.',
144
+ ' from package.json scripts. Unmappable settings become',
145
+ ' TODO(vx-migrate) comments.',
136
146
  ' --dry Print the generated files instead of writing them.',
137
- ' --force Overwrite existing vx.config.* / vx-preset.ts files.',
147
+ ' --force Overwrite existing vx.config.* files.',
148
+ ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
149
+ '',
150
+ 'Shell completions:',
151
+ ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
152
+ ' verbs (plugin verbs of this workspace included) and',
153
+ ' every flag of each. `eval "$(vx completions bash)"`,',
154
+ ' or write it where the shell loads completions from.',
138
155
  '',
139
156
  'Config lock:',
140
157
  ' vx lock Evaluate every vx.config in the current environment and',
@@ -150,6 +167,15 @@ export function helpText(pluginCommands: readonly string[] = []): string {
150
167
  ].join('\n')
151
168
  }
152
169
 
170
+ /**
171
+ * Every argument error ends with this. `vx <verb> --help` prints the
172
+ * reference (2026-09-04); before that there was nowhere to send a user who
173
+ * got a flag wrong, so the errors just said no.
174
+ */
175
+ export function seeHelp(verb: string): string {
176
+ return ` (see \`vx ${verb} --help\`)`
177
+ }
178
+
153
179
  /**
154
180
  * The documented `--flags` of one verb, read from the help text's
155
181
  * sections headed `… (for <verb>):` so there is no second list to drift —
@@ -166,3 +192,5 @@ export function documentedFlags(verb: string): string[] {
166
192
  }
167
193
  return [...flags]
168
194
  }
195
+
196
+ export { CORE_VERBS } from '../util/index.js'
package/src/cli/index.ts CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  import { VERSION } from '../version.js'
6
6
  import { runCmd } from './run.js'
7
- import { printHelp } from './help.js'
8
- import { pluginCommandHelp, resolvePluginCommand } from './plugin-commands.js'
9
- import { UserError } from '../util/index.js'
7
+ import { CORE_VERBS, printHelp } from './help.js'
8
+ import { pluginCommandHelp, resolvePluginCommand, pluginVerbs } from './plugin-commands.js'
9
+ import { MOVED_VERBS, nearest, UserError } from '../util/index.js'
10
10
 
11
11
  // Every verb but `run` is imported when invoked. `vx run` is the hot path
12
12
  // and nearly every invocation; the other verbs' modules are code that
@@ -18,6 +18,16 @@ import { UserError } from '../util/index.js'
18
18
  export async function run(argv: readonly string[]): Promise<number> {
19
19
  const [command, ...rest] = argv
20
20
 
21
+ // `vx <verb> --help` is the universal reflex, and every verb answered
22
+ // `unknown flag: --help` and exited 1 (walkthrough, 2026-09-04). Only args
23
+ // BEFORE a `--` count: `vx run build -- --help` forwards it to the task,
24
+ // which is the one place `--help` is not being asked of vx. Core verbs
25
+ // only — a plugin verb owns its own arguments, `--help` included.
26
+ if (command !== undefined && wantsHelp(command, rest)) {
27
+ printHelp(await pluginCommandHelp(), command)
28
+ return 0
29
+ }
30
+
21
31
  switch (command) {
22
32
  case undefined:
23
33
  case '--help':
@@ -37,13 +47,8 @@ export async function run(argv: readonly string[]): Promise<number> {
37
47
  return await (await import('./cache.js')).cacheCmd(rest)
38
48
  case 'lock':
39
49
  return await (await import('./lock.js')).lockCmd(rest)
40
- case 'migrate':
41
- return await (await import('./migrate.js')).migrateCmd(rest)
42
50
  case 'init':
43
- // A workspace from nowhere: package.json scripts are the source.
44
- return await (
45
- await import('./migrate.js')
46
- ).migrateCmd(['--from', 'scripts', ...rest], { init: true })
51
+ return await (await import('./init.js')).initCmd(rest)
47
52
  case 'upgrade':
48
53
  return await (await import('./upgrade.js')).upgradeCmd(rest)
49
54
  case 'show':
@@ -55,8 +60,8 @@ export async function run(argv: readonly string[]): Promise<number> {
55
60
  return await (await import('./why.js')).whyCmd(rest)
56
61
  case 'last':
57
62
  return await (await import('./last.js')).lastCmd(rest)
58
- case 'prune':
59
- return await (await import('./prune.js')).pruneWorkspaceCmd(rest)
63
+ case 'completions':
64
+ return await (await import('./completions.js')).completionsCmd(rest, await pluginVerbs())
60
65
  default: {
61
66
  // Not a core verb: a plugin declared in the workspace around the cwd
62
67
  // may own it (`VxPlugin.commands`). Core verbs were matched above, so
@@ -82,6 +87,14 @@ export async function run(argv: readonly string[]): Promise<number> {
82
87
  resolved !== null && 'loadError' in resolved
83
88
  ? `\n (plugin verbs could not be looked up: vx.workspace failed to load: ${resolved.loadError})`
84
89
  : ''
90
+ // A verb core owned once (`migrate`, `prune`) and a package owns now:
91
+ // the pointer, but only after the plugins had their chance — a
92
+ // workspace that declares a plugin verb of that name keeps it.
93
+ const moved = MOVED_VERBS[command]
94
+ if (moved !== undefined) {
95
+ process.stderr.write(`${moved}${loadNote}\n`)
96
+ return 1
97
+ }
85
98
  if (command === 'serve' || command === 'dev') {
86
99
  // vx core is only a task runner — it has no service layer of its
87
100
  // own. A dashboard, remote cache, distributed execution, etc. are
@@ -97,7 +110,7 @@ export async function run(argv: readonly string[]): Promise<number> {
97
110
  )
98
111
  return 1
99
112
  }
100
- process.stderr.write(`vx: unknown command: ${command}${loadNote}\n`)
113
+ process.stderr.write(`vx: unknown command: ${command}${didYouMeanVerb(command)}${loadNote}\n`)
101
114
  printHelp()
102
115
  return 1
103
116
  }
@@ -105,12 +118,38 @@ export async function run(argv: readonly string[]): Promise<number> {
105
118
  }
106
119
 
107
120
  // Re-exports for tests + programmatic embedders.
108
- export { detectFlow, parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
121
+ export {
122
+ detectFlow,
123
+ parseConcurrency,
124
+ parseRunArgs,
125
+ resolveRunOptions,
126
+ type RunArgs,
127
+ } from './run.js'
109
128
  export { parsePruneArgs, parseDuration, parseSize } from './cache.js'
110
129
  export { parseLockArgs, type LockArgs } from './lock.js'
111
- export { parseMigrateArgs, type MigrateArgs } from './migrate.js'
130
+ export { parseInitArgs, type InitArgs } from './init.js'
112
131
  export { parseShowArgs, type ShowArgs } from './show.js'
113
132
  export { parseWhyArgs } from './why.js'
114
133
  export { parseLastArgs } from './last.js'
115
- export { parsePruneWorkspaceArgs } from './prune.js'
116
134
  export { formatBytes } from './format.js'
135
+ export { registerCoreAlias } from './core-alias.js'
136
+
137
+ /**
138
+ * Is this invocation asking for help rather than work? See the call site for
139
+ * why the scan stops at `--`, and why plugin verbs are excluded.
140
+ */
141
+ function wantsHelp(command: string, rest: readonly string[]): boolean {
142
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return false
143
+ const sep = rest.indexOf('--')
144
+ const own = sep === -1 ? rest : rest.slice(0, sep)
145
+ return own.includes('--help') || own.includes('-h')
146
+ }
147
+
148
+ /** ` Did you mean run?` for a verb within two edits of a core one — the same
149
+ * hint a task or flag typo gets. Plugin verbs are not listed: resolving
150
+ * them loads the workspace, and this path is reached only when that
151
+ * lookup found nothing. */
152
+ function didYouMeanVerb(verb: string): string {
153
+ const best = nearest(verb, CORE_VERBS)
154
+ return best === undefined ? '' : `. Did you mean ${best}?`
155
+ }