@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/help.ts CHANGED
@@ -1,5 +1,36 @@
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
+ // The verb goes into a RegExp, so it is escaped: `vx 'r.n' --help` is an
15
+ // unknown verb, not a pattern that matches `vx run` and answers with a
16
+ // help cut for a verb that does not exist.
17
+ const quoted = verb.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
18
+ const names = (line: string): boolean =>
19
+ new RegExp(`^\\s*vx ${quoted}( |$)`).test(line) ||
20
+ line.includes(`(for ${verb})`) ||
21
+ line.includes(`(for ${verb} `)
22
+ const kept: string[] = []
23
+ for (const block of blocks.slice(1)) {
24
+ const lines = block.split('\n')
25
+ if (lines[0] === 'Usage:') {
26
+ const own = lines.slice(1).filter(names)
27
+ if (own.length > 0) kept.push(['Usage:', ...own].join('\n'))
28
+ continue
29
+ }
30
+ if (lines.some(names)) kept.push(block)
31
+ }
32
+ if (kept.length === 0) return helpText()
33
+ return [title, ...kept, 'Full reference: vx help', ''].join('\n\n')
3
34
  }
4
35
 
5
36
  export function helpText(pluginCommands: readonly string[] = []): string {
@@ -9,16 +40,15 @@ export function helpText(pluginCommands: readonly string[] = []): string {
9
40
  'Usage:',
10
41
  ' vx run [OPTIONS] [TASK | PKG#TASK] [-- forwarded-args...]',
11
42
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
12
- ' vx cache prune [--older-than <duration>] [--max-size <bytes>]',
43
+ ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
13
44
  ' vx lock [--check]',
14
- ' vx init [--dry] [--force]',
15
- ' vx migrate [--from turbo|nx|scripts] [--dry] [--force]',
45
+ ' vx init [--dry] [--force] [--mjs]',
16
46
  ' 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]',
47
+ ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
48
+ ' vx info [--format pretty|json] [--cache-dir <path>]',
49
+ ' vx why [TASK | PKG#TASK] [--run <runId>] [--format pretty|json] [--cache-dir <path>]',
50
+ ' vx last [RUNID] [--list[=N]] [--format pretty|json] [--cache-dir <path>]',
51
+ ' vx completions bash|zsh|fish',
22
52
  ' vx help',
23
53
  ' vx version',
24
54
  '',
@@ -28,13 +58,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
28
58
  ' --filter <pat> pnpm-style filter (repeatable). Examples:',
29
59
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
30
60
  ' foo^..., !foo, [<since>]',
31
- ' --affected[=<base>] Sugar for --filter "[<base>]". Default base =',
61
+ ' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
32
62
  ' origin/HEAD (falls back to HEAD~1).',
33
63
  " pkg#task Run a specific project's task directly.",
34
64
  '',
35
65
  '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.',
66
+ ' --concurrency <n> Max parallel tasks (default: the cores this process may use); `50%` = half of them.',
67
+ ' --exclude-dependencies[=names] Skip dependsOn edges. No value = all; comma list = specific names.',
38
68
  ' --no-cache Disable caching entirely (no reads, no writes, outputs left alone).',
39
69
  ' --force Re-execute everything (skip reads) but still refresh the cache (writes on).',
40
70
  ' --cache <spec> Per-layer read/write control. Comma list of <layer>:<flags>',
@@ -50,23 +80,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
50
80
  ' --timeout <ms> Default per-task timeout for tasks without their own exec.timeout',
51
81
  ' (above VX_TASK_TIMEOUT env + workspace timeout). Kills + fails',
52
82
  ' 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.',
83
+ ' --continue[=<mode>] What a failed task takes down: never | deps-ok (default, skips',
84
+ ' its dependents) | always (bare form; dependents run, never save).',
65
85
  ' --frozen Load configs from vx-lock.json (CI; pair with vx lock --check).',
66
86
  ' --output-logs <mode> full | errors-only | hash-only | none — overrides the flow default',
67
- ' --download <mode> all (default) | toplevel | none — where remote outputs land',
68
87
  ' (focused without --all/--filter/--affected, broad with, full in CI).',
69
- ' --verbosity <n> 0=quiet, 1=verbose summary, 2+=debug (reserved).',
88
+ ' --download <mode> all (default) | toplevel | none — where remote outputs land.',
89
+ ' --verbosity <n> 0 (default) or 1+: print a per-task summary table after the run.',
70
90
  '',
71
91
  'Planning (for run — skips execution):',
72
92
  ' --dry[=text|json] Print the task graph + cache hit/miss prediction.',
@@ -75,6 +95,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
75
95
  'Artifacts (for run):',
76
96
  ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
77
97
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
98
+ ' --report[=markdown] Print a markdown run report to stdout after the run.',
99
+ ' --report-file <path> Append that report to <path> (for $GITHUB_STEP_SUMMARY).',
100
+ ' --tag <k=v> Label this invocation (repeatable); recorded on its history row.',
78
101
  '',
79
102
  'Extensions (plugins):',
80
103
  ' vx core runs tasks in-process and nothing else. A dashboard, remote',
@@ -97,28 +120,29 @@ export function helpText(pluginCommands: readonly string[] = []): string {
97
120
  'Cache management:',
98
121
  ' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
99
122
  ' vx cache prune --max-size 1G Keep total cache under 1 GB (LRU eviction).',
123
+ ' --dry-run Say what the policy would reap; delete nothing.',
124
+ ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
100
125
  '',
101
126
  ' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
127
+ ' Cache statistics — directory, entry count, size — are part of `vx info`.',
102
128
  '',
103
129
  'Introspection:',
104
130
  ' vx show List every project: name, dir, declared task count.',
105
131
  " vx show <project> Print the project's LIVE resolved config (fresh",
106
132
  ' evaluation, not the lock), one block per task.',
107
133
  " vx show <pkg>#<task> Print a single task's resolved config.",
134
+ ' vx show <task> That task in every project declaring it.',
108
135
  ' --format <fmt> pretty (default) | json.',
109
136
  ' vx info Workspace doctor: vx/bun/git versions, project +',
110
137
  ' task counts, cache dir/entries/size, recent runs,',
111
138
  ' lock status.',
139
+ ' --format <fmt> pretty (default) | json.',
112
140
  ' vx stats Deprecated: alias of vx info.',
113
141
  " vx why <task> Why did this task re-run? Compares the task's latest",
114
142
  ' run (or --run <id>) against its previous run: names the',
115
143
  ' exact changed cache-key components (files / env / runtime',
116
144
  ' / upstream) from the persisted input fingerprints.',
117
145
  ' --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
146
  " vx last [runId] Replay a recorded run's summary from the local history —",
123
147
  ' header (command, when, branch, counts) plus the per-task',
124
148
  ' table, failures first. No re-execution, no cache probe.',
@@ -127,14 +151,17 @@ export function helpText(pluginCommands: readonly string[] = []): string {
127
151
  '',
128
152
  'Migration:',
129
153
  ' 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.',
154
+ ' from package.json scripts. Unmappable settings become',
155
+ ' TODO(vx-migrate) comments.',
136
156
  ' --dry Print the generated files instead of writing them.',
137
- ' --force Overwrite existing vx.config.* / vx-preset.ts files.',
157
+ ' --force Overwrite existing vx.config.* files.',
158
+ ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
159
+ '',
160
+ 'Shell completions:',
161
+ ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
162
+ ' verbs (plugin verbs of this workspace included) and',
163
+ ' every flag of each. `eval "$(vx completions bash)"`,',
164
+ ' or write it where the shell loads completions from.',
138
165
  '',
139
166
  'Config lock:',
140
167
  ' vx lock Evaluate every vx.config in the current environment and',
@@ -150,6 +177,15 @@ export function helpText(pluginCommands: readonly string[] = []): string {
150
177
  ].join('\n')
151
178
  }
152
179
 
180
+ /**
181
+ * Every argument error ends with this. `vx <verb> --help` prints the
182
+ * reference (2026-09-04); before that there was nowhere to send a user who
183
+ * got a flag wrong, so the errors just said no.
184
+ */
185
+ export function seeHelp(verb: string): string {
186
+ return ` (see \`vx ${verb} --help\`)`
187
+ }
188
+
153
189
  /**
154
190
  * The documented `--flags` of one verb, read from the help text's
155
191
  * sections headed `… (for <verb>):` so there is no second list to drift —
@@ -166,3 +202,5 @@ export function documentedFlags(verb: string): string[] {
166
202
  }
167
203
  return [...flags]
168
204
  }
205
+
206
+ 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,14 +60,14 @@ 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
63
68
  // nothing here can shadow them.
64
69
  const resolved = await resolvePluginCommand(command)
65
- if (resolved !== null && !('loadError' in resolved)) {
70
+ if (resolved !== null && !('loadError' in resolved) && !('declaredVerbs' in resolved)) {
66
71
  const code = await resolved.command.run(rest, resolved.ctx)
67
72
  // A plugin is a boundary: a JS-authored verb that resolves nothing
68
73
  // would reach `process.exit(undefined)` and read as SUCCESS. A verb
@@ -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,20 +110,72 @@ 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`)
101
- printHelp()
113
+ // One line, as a verb's own unknown flag or subcommand gets; the full
114
+ // help after a typo was a hundred lines past the hint that mattered.
115
+ // The verbs this workspace's plugins declare are verbs HERE, so they
116
+ // join the "did you mean" set, and when nothing is close the second
117
+ // line says where a verb can come from: `vx mpc` in a workspace
118
+ // declaring `mcp` read as a plain unknown command, and `vx mcp`
119
+ // before the plugin was declared said nothing about the file that
120
+ // would declare it (2026-09-20).
121
+ const declaredVerbs =
122
+ resolved !== null && 'declaredVerbs' in resolved ? resolved.declaredVerbs : []
123
+ const guess = didYouMeanVerb(command, declaredVerbs)
124
+ process.stderr.write(
125
+ `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
126
+ (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
127
+ )
102
128
  return 1
103
129
  }
104
130
  }
105
131
  }
106
132
 
107
133
  // Re-exports for tests + programmatic embedders.
108
- export { detectFlow, parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
134
+ export {
135
+ detectFlow,
136
+ parseConcurrency,
137
+ parseRunArgs,
138
+ resolveRunOptions,
139
+ type RunArgs,
140
+ } from './run.js'
109
141
  export { parsePruneArgs, parseDuration, parseSize } from './cache.js'
110
142
  export { parseLockArgs, type LockArgs } from './lock.js'
111
- export { parseMigrateArgs, type MigrateArgs } from './migrate.js'
143
+ export { parseInitArgs, type InitArgs } from './init.js'
112
144
  export { parseShowArgs, type ShowArgs } from './show.js'
113
145
  export { parseWhyArgs } from './why.js'
114
146
  export { parseLastArgs } from './last.js'
115
- export { parsePruneWorkspaceArgs } from './prune.js'
116
147
  export { formatBytes } from './format.js'
148
+ export { registerCoreAlias } from './core-alias.js'
149
+
150
+ /**
151
+ * Is this invocation asking for help rather than work? See the call site for
152
+ * why the scan stops at `--`, and why plugin verbs are excluded.
153
+ */
154
+ function wantsHelp(command: string, rest: readonly string[]): boolean {
155
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return false
156
+ const sep = rest.indexOf('--')
157
+ const own = sep === -1 ? rest : rest.slice(0, sep)
158
+ return own.includes('--help') || own.includes('-h')
159
+ }
160
+
161
+ /** ` Did you mean run?` for a verb within two edits of a core one, or of a
162
+ * verb this workspace's plugins declare — the same hint a task or flag typo
163
+ * gets. The plugin verbs come from the lookup that just failed, so they
164
+ * cost no second load. */
165
+ function didYouMeanVerb(verb: string, pluginVerbsHere: readonly string[] = []): string {
166
+ const best = nearest(verb, [...CORE_VERBS, ...pluginVerbsHere])
167
+ return best === undefined ? '' : `. Did you mean ${best}?`
168
+ }
169
+
170
+ /**
171
+ * Where a verb that is neither core nor declared COULD come from. Only when
172
+ * nothing is close enough to guess: after a plain typo the guess is the
173
+ * answer, and a second line would bury it.
174
+ */
175
+ function verbSourceNote(resolved: unknown, declaredVerbs: readonly string[]): string {
176
+ if (declaredVerbs.length > 0) {
177
+ return ` This workspace's plugins declare: ${[...declaredVerbs].sort().join(', ')}.\n`
178
+ }
179
+ const here = resolved === null ? 'there is no workspace here' : 'this workspace declares none'
180
+ return ` A plugin declared in vx.workspace.ts can add verbs; ${here}.\n`
181
+ }
package/src/cli/info.ts CHANGED
@@ -1,110 +1,181 @@
1
1
  // `vx info` — workspace doctor printout. One screen of environment +
2
2
  // workspace + cache facts for bug reports and quick sanity checks.
3
- // `vx stats` is a deprecated alias (info absorbed it).
3
+ // `vx stats` is a deprecated alias (info absorbed it). The facts come
4
+ // from the orchestrator's `collectInfo` (doctor.ts), which `vx mcp` reads
5
+ // too; this file parses the flags and renders the rows.
4
6
 
5
- import { Cache } from '../cache/index.js'
6
- import { VERSION } from '../version.js'
7
- import {
8
- findWorkspaceRoot,
9
- listProjects,
10
- loadProjectConfig,
11
- loadWorkspace,
12
- loadWorkspaceConfig,
13
- lockfilePath,
14
- resolveCacheDir,
15
- } from '../workspace/index.js'
7
+ import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
+ import { seeHelp } from './help.js'
9
+ import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
16
10
  import { formatBytes } from './format.js'
11
+ import { MIN_BUN } from '../util/index.js'
17
12
 
18
- export async function infoCmd(args: readonly string[]): Promise<number> {
19
- if (args.length > 0) {
20
- process.stderr.write(`vx info: unknown argument: ${args[0]}\n`)
21
- return 1
22
- }
23
- const root = await findWorkspaceRoot(process.cwd())
24
- const metas = await listProjects(await loadWorkspace(root))
13
+ export interface InfoArgs {
14
+ format: 'pretty' | 'json'
15
+ /** `--cache-dir`: report on the cache a run with the same flag uses. */
16
+ cacheDir?: string
17
+ error?: string
18
+ }
25
19
 
26
- let taskCount = 0
27
- await Promise.all(
28
- metas.map(async (meta) => {
29
- if (meta.configPath === null) return
30
- // A broken config must not take the doctor down with it — it
31
- // just contributes zero tasks to the count.
32
- try {
33
- const config = await loadProjectConfig(meta.configPath)
34
- taskCount += Object.keys(config.tasks ?? {}).length
35
- } catch {
36
- // counted as zero
20
+ function parseInfoArgs(args: readonly string[]): InfoArgs {
21
+ const out: InfoArgs = { format: 'pretty' }
22
+ for (let i = 0; i < args.length; i++) {
23
+ const a = args[i]
24
+ if (a === '--format' || a?.startsWith('--format=')) {
25
+ const v = a === '--format' ? args[++i] : a.slice('--format='.length)
26
+ if (v !== 'pretty' && v !== 'json') {
27
+ return { ...out, error: `--format must be pretty or json${seeHelp('info')}` }
37
28
  }
38
- }),
39
- )
40
-
41
- const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
42
- const cache = new Cache(cacheDir)
43
- let stats
44
- try {
45
- stats = cache.stats()
46
- } finally {
47
- cache.close()
29
+ out.format = v
30
+ continue
31
+ }
32
+ const cd = parseCacheDirFlag(args, i)
33
+ if (cd !== null) {
34
+ if ('error' in cd) return { ...out, error: cd.error }
35
+ out.cacheDir = cd.cacheDir
36
+ i = cd.next
37
+ continue
38
+ }
39
+ return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
48
40
  }
41
+ return out
42
+ }
49
43
 
50
- const lockPresent = await Bun.file(lockfilePath(root)).exists()
44
+ export async function infoCmd(args: readonly string[]): Promise<number> {
45
+ const parsed = parseInfoArgs(args)
46
+ if (parsed.error) {
47
+ process.stderr.write(`vx info: ${parsed.error}\n`)
48
+ return 1
49
+ }
50
+ // `collectInfo` takes a resolved directory from `vx mcp` too, where one
51
+ // not made yet is a workspace that never ran; the flag is the user's,
52
+ // and a directory it names that is not there is a typo (item 900).
53
+ if (parsed.cacheDir !== undefined) namedCacheDir(parsed.cacheDir)
54
+ const facts = await collectInfo(process.cwd(), {
55
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
56
+ warn: warnToStderr,
57
+ })
58
+ process.stdout.write(
59
+ parsed.format === 'json' ? `${JSON.stringify(facts, null, 2)}\n` : `${renderInfo(facts)}\n`,
60
+ )
61
+ return 0
62
+ }
51
63
 
64
+ export function renderInfo(f: InfoFacts): string {
52
65
  const rows: [string, string][] = [
53
- ['vx', VERSION],
54
- ['bun', Bun.version],
55
- ['git', gitVersion()],
56
- // The one `git status` walk per run is the warm path's critical path on
57
- // a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
58
- // own caches make it near-free after the first run, and they are OFF by
59
- // default — say so, since nothing else in a run would.
60
- ['git status cache', gitStatusCache(root)],
61
- ['workspace root', root],
62
- ['projects', `${metas.length} (${taskCount} task${taskCount === 1 ? '' : 's'})`],
63
- ['cache dir', cacheDir],
64
- ['cache entries', `${stats.entryCount} (${formatBytes(stats.totalBytes)})`],
65
- ['runs (24h)', `${stats.runCountLast24h} (${stats.hitCountLast24h} cache hits)`],
66
- ['vx-lock.json', lockPresent ? 'yes' : 'no'],
66
+ ['vx', f.vx],
67
+ // The prose stays in the rendered row; `f.bun` itself is the bare
68
+ // version a script reads (doctor.ts says why).
69
+ [
70
+ 'bun',
71
+ f.bunSupported
72
+ ? f.bun
73
+ : `${f.bun} — unsupported, vx needs >= ${MIN_BUN.join('.')}; answers may be wrong`,
74
+ ],
75
+ ['git', f.git ?? '(not found)'],
76
+ ['git status cache', renderGitStatusCache(f.gitStatusCache)],
77
+ ['workspace root', f.workspaceRoot],
78
+ ['projects', describeProjects(f)],
79
+ // Only when a config did not load: the count above says "zero" for
80
+ // it, and this row says which and why.
81
+ ...(f.configErrors.length > 0
82
+ ? ([['config errors', f.configErrors.map((e) => `${e.path}: ${e.message}`).join('; ')]] as [
83
+ string,
84
+ string,
85
+ ][])
86
+ : []),
87
+ ['plugins', describePlugins(f.plugins)],
88
+ ['workers', describeWorkers(f.workers)],
89
+ ['memory', describeMemory(f.memory)],
90
+ ['cache dir', f.cacheDir],
91
+ ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
92
+ ['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
93
+ // Only when there is something to say.
94
+ ...(f.orphans.artifacts > 0
95
+ ? ([
96
+ [
97
+ 'orphans',
98
+ `${f.orphans.artifacts} artifact${f.orphans.artifacts === 1 ? '' : 's'} (${formatBytes(f.orphans.bytes)}) the index does not know — \`vx cache prune\` reaps them`,
99
+ ],
100
+ ] as [string, string][])
101
+ : []),
102
+ // Task runs, not invocations (which `vx last` calls runs): the count
103
+ // the hits are a share of.
104
+ ['task runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
105
+ ['flaky tasks', describeFlakyTasks(f.flakyTasks)],
106
+ ['sandbox', describeSandbox(f.sandbox)],
107
+ ['vx-lock.json', f.lockfile ? 'yes' : 'no'],
67
108
  ]
68
109
  const labelW = Math.max(...rows.map(([label]) => label.length))
69
- const lines = rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`)
70
- process.stdout.write(`${lines.join('\n')}\n`)
71
- return 0
110
+ return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
72
111
  }
73
112
 
74
- /** Whether git's fsmonitor / untracked cache are on, with the remedy when not. */
75
- function gitStatusCache(root: string): string {
76
- try {
77
- const p = Bun.spawnSync({
78
- cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
79
- cwd: root,
80
- stdout: 'pipe',
81
- stderr: 'pipe',
82
- })
83
- const out = p.exitCode === 0 ? new TextDecoder().decode(p.stdout) : ''
84
- const on = (key: string): boolean =>
85
- new RegExp(`^core\\.${key} (true|1|yes|on)$`, 'im').test(out)
86
- const fsmonitor = on('fsmonitor')
87
- const untracked = on('untrackedcache')
88
- if (fsmonitor && untracked) return 'fsmonitor + untrackedCache on'
89
- const missing = [
90
- ...(fsmonitor ? [] : ['core.fsmonitor']),
91
- ...(untracked ? [] : ['core.untrackedCache']),
92
- ]
93
- return `${missing.join(', ')} off — \`git config ${missing[0]} true\` makes every run's status walk near-free on a large tree`
94
- } catch {
95
- return '(unknown)'
96
- }
113
+ /**
114
+ * `available (3 tasks declare exec.sandbox)`, or `unavailable — <why>; 3
115
+ * tasks declare exec.sandbox and will fail`: a declared sandbox whose
116
+ * runtime cannot start fails the task, so the doctor names it before a run
117
+ * does.
118
+ */
119
+ function describeSandbox(s: InfoFacts['sandbox']): string {
120
+ const declared = `${s.declared} task${s.declared === 1 ? '' : 's'} declare${s.declared === 1 ? 's' : ''} exec.sandbox`
121
+ if (s.available) return `available (${declared})`
122
+ return `unavailable — ${s.reason}; ${declared}${s.declared > 0 ? ' and will fail' : ''}`
97
123
  }
98
124
 
99
- function gitVersion(): string {
100
- try {
101
- const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
102
- if (p.exitCode !== 0) return '(not found)'
103
- return new TextDecoder()
104
- .decode(p.stdout)
105
- .trim()
106
- .replace(/^git version /, '')
107
- } catch {
108
- return '(not found)'
125
+ /** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — vx.workspace.ts (4 cores)`. */
126
+ export function describeWorkers(w: InfoFacts['workers']): string {
127
+ const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
128
+ if (w.source === 'workspace') return `${w.count} — vx.workspace.ts (${w.cores} cores${quota})`
129
+ if (w.source === 'cgroup')
130
+ return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${w.cores} cores`
131
+ return `${w.count} — the CPU count${quota}`
132
+ }
133
+
134
+ /** `13 GB usable — cgroup limit; the machine has 16 GB`, or `16 GB`. */
135
+ export function describeMemory(m: InfoFacts['memory']): string {
136
+ if (m.cgroupLimitBytes !== null && m.usableBytes < m.totalBytes) {
137
+ return `${formatBytes(m.usableBytes)} usable — cgroup limit; the machine has ${formatBytes(m.totalBytes)}`
109
138
  }
139
+ return formatBytes(m.totalBytes)
140
+ }
141
+
142
+ function trimCores(n: number): string {
143
+ return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, '')
144
+ }
145
+
146
+ /** `2 — app#test (3 of 7 runs failed on unchanged inputs); api#e2e (1 of 4)`, or `none`. */
147
+ function describeProjects(f: InfoFacts): string {
148
+ const tasks = `${f.tasks} task${f.tasks === 1 ? '' : 's'}`
149
+ const n = f.configErrors.length
150
+ if (n === 0) return `${f.projects} (${tasks})`
151
+ return `${f.projects} (${tasks} · ${n} config${n === 1 ? '' : 's'} did not load)`
152
+ }
153
+
154
+ function describeFlakyTasks(tasks: readonly FlakyTask[]): string {
155
+ if (tasks.length === 0) return 'none'
156
+ const parts = tasks.map(
157
+ (t, i) =>
158
+ `${t.taskId} (${t.failures} of ${t.passes + t.failures} runs failed${i === 0 ? ' on unchanged inputs' : ''})`,
159
+ )
160
+ return `${tasks.length} — ${parts.join('; ')}`
161
+ }
162
+
163
+ function describePlugins(
164
+ plugins: ReadonlyArray<{ name: string; seams: readonly string[] }>,
165
+ ): string {
166
+ if (plugins.length === 0) return 'none'
167
+ const parts = plugins.map(
168
+ (p) => `${p.name} (${p.seams.length === 0 ? 'no seams' : p.seams.join(', ')})`,
169
+ )
170
+ return `${plugins.length} — ${parts.join('; ')}`
171
+ }
172
+
173
+ function renderGitStatusCache(c: InfoFacts['gitStatusCache']): string {
174
+ if (c === null) return '(unknown)'
175
+ if (c.fsmonitor && c.untrackedCache) return 'fsmonitor + untrackedCache on'
176
+ const missing = [
177
+ ...(c.fsmonitor ? [] : ['core.fsmonitor']),
178
+ ...(c.untrackedCache ? [] : ['core.untrackedCache']),
179
+ ]
180
+ return `${missing.join(', ')} off`
110
181
  }