@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/cli/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
 
4
4
  import { VERSION } from '../version.js'
5
5
  import { checkSettingsEnv } from '../settings.js'
6
- import { CORE_VERBS, flagHint, printHelp, refusedWord, seeHelp } from './help.js'
6
+ import { CORE_VERBS, flagHint, printHelp, refuse, refusedWord, seeHelp } from './help.js'
7
7
  import {
8
+ detectColors,
8
9
  errorDocument,
9
10
  hideEnvFromTasks,
10
11
  sayNotice,
@@ -14,6 +15,7 @@ import {
14
15
  maskedLine,
15
16
  nearest,
16
17
  refusalNotice,
18
+ thrownMessage,
17
19
  ui,
18
20
  UserError,
19
21
  wantsJson,
@@ -28,6 +30,7 @@ import {
28
30
  const plugins = () => import('./plugin-commands.js')
29
31
 
30
32
  export async function run(argv: readonly string[]): Promise<number> {
33
+ refuseLossyArgs(argv)
31
34
  const [command, ...rest] = argv
32
35
  await hideEnvFromTasks()
33
36
 
@@ -41,8 +44,11 @@ export async function run(argv: readonly string[]): Promise<number> {
41
44
  return 0
42
45
  }
43
46
  // Each setting's variable once, before any verb: a renamed one is named,
44
- // and a deep reader trusts what it reads.
45
- checkSettingsEnv()
47
+ // and a deep reader trusts what it reads. Not for help, as `vx run
48
+ // --help` above is not: help is where a setting's values are read. Nor
49
+ // for `vx info`, which reports a refused one (doctor.ts).
50
+ if (command !== 'help' && command !== '--help' && command !== '-h' && command !== 'info')
51
+ checkSettingsEnv()
46
52
 
47
53
  try {
48
54
  return await dispatch(command, rest)
@@ -51,9 +57,23 @@ export async function run(argv: readonly string[]): Promise<number> {
51
57
  }
52
58
  }
53
59
 
60
+ /**
61
+ * Bun decodes argv as lossy UTF-8 and exposes no raw bytes on macOS, so
62
+ * `-- $'a\xfe'` and `-- $'a\xff'` both became `a` U+FFFD: the task got
63
+ * bytes nobody typed and the two runs shared a key (a stale hit). A real
64
+ * U+FFFD cannot be told from a replaced byte, so any arg holding one is refused.
65
+ */
66
+ function refuseLossyArgs(argv: readonly string[]): void {
67
+ const at = argv.findIndex((a) => a.includes('�'))
68
+ if (at === -1) return
69
+ throw new UserError(
70
+ `argument ${at + 1} (${JSON.stringify(argv[at])}) is not valid UTF-8, or holds U+FFFD; vx cannot pass it on byte for byte`,
71
+ )
72
+ }
73
+
54
74
  /**
55
75
  * A crash from a hand-broken lock entry is that entry's fault: runs validate
56
- * no entry, so it surfaced as a raw TypeError (M, 2026-10-14). The lock is
76
+ * no entry, so it surfaced as a raw TypeError (2026-10-14). The lock is
57
77
  * read only on the crash path, so a normal run pays nothing.
58
78
  */
59
79
  async function brokenLockNamed(err: unknown): Promise<unknown> {
@@ -61,7 +81,8 @@ async function brokenLockNamed(err: unknown): Promise<unknown> {
61
81
  try {
62
82
  const { findWorkspaceRoot, LOCK_INVALID, lockEntryBroken } =
63
83
  await import('../workspace/index.js')
64
- if (lockEntryBroken(await findWorkspaceRoot(process.cwd()))) return new UserError(LOCK_INVALID)
84
+ if (lockEntryBroken(await findWorkspaceRoot(process.cwd())))
85
+ return new UserError(LOCK_INVALID, 'VX_E_LOCK')
65
86
  } catch {}
66
87
  return err
67
88
  }
@@ -85,13 +106,21 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
85
106
  }
86
107
  case '--help':
87
108
  case '-h':
109
+ if (rest.length > 0) return dispatch('help', rest)
88
110
  await printHelp(await (await plugins()).pluginCommandHelp())
89
111
  return 0
90
112
  case 'help': {
91
- // `vx help run` is the same question as `vx run --help`. A plugin verb
92
- // owns its own help, so anything but a core verb gets the whole
93
- // reference, which lists the plugin verbs.
94
- const verb = rest[0]
113
+ // `vx help run` is the same question as `vx run --help`, and so is
114
+ // `vx help <plugin verb>`: a plugin verb owns its own help.
115
+ const [verb, extra] = rest
116
+ if (extra !== undefined) {
117
+ return refuse(
118
+ 'help',
119
+ rest,
120
+ `${refusedWord(extra)}: ${extra}${flagHint('help', extra)} (see \`vx help\`)`,
121
+ 'VX_E_USAGE',
122
+ )
123
+ }
95
124
  const core = verb !== undefined && (CORE_VERBS as readonly string[]).includes(verb)
96
125
  if (verb !== undefined && !core) {
97
126
  // A name that is no verb here is the typo `vx rnu` is, not a request
@@ -99,22 +128,23 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
99
128
  const resolved = await (await plugins()).resolvePluginCommand(verb)
100
129
  const unknown = resolved === null || 'declaredVerbs' in resolved
101
130
  if (unknown && verb.startsWith('-')) {
102
- sayNotice(
103
- 'error',
104
- 'vx help',
131
+ return refuse(
132
+ 'help',
133
+ rest,
105
134
  `unknown flag: ${verb}${flagHint('help', verb)} (see \`vx help\`)`,
135
+ 'VX_E_USAGE',
106
136
  )
107
- return 1
108
137
  }
109
138
  if (unknown) {
110
139
  const declared = resolved === null ? [] : resolved.declaredVerbs
111
- sayNotice(
112
- 'error',
113
- 'vx help',
140
+ return refuse(
141
+ 'help',
142
+ rest,
114
143
  `unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
144
+ 'VX_E_UNKNOWN_COMMAND',
115
145
  )
116
- return 1
117
146
  }
147
+ if (resolved !== null && !('loadError' in resolved)) return dispatch(verb, ['--help'])
118
148
  }
119
149
  await printHelp(await (await plugins()).pluginCommandHelp(), core ? verb : undefined)
120
150
  return 0
@@ -125,14 +155,17 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
125
155
  // takes no more of is refused, as every other verb refuses one.
126
156
  const extra = rest[0]
127
157
  if (extra !== undefined) {
128
- sayNotice(
129
- 'error',
130
- 'vx version',
158
+ return refuse(
159
+ 'version',
160
+ rest,
131
161
  `${refusedWord(extra)}: ${extra}${flagHint('version', extra)}${seeHelp('version')}`,
162
+ 'VX_E_USAGE',
132
163
  )
133
- return 1
134
164
  }
135
- process.stdout.write(`vx ${VERSION}\n`)
165
+ // Painted, and the same words piped: a script reads `vx <version>`.
166
+ process.stdout.write(
167
+ `${ui.inline([ui.cmd('vx'), ' ', ui.tone('info', VERSION)], detectColors(process.stdout))}\n`,
168
+ )
136
169
  return 0
137
170
  }
138
171
  case 'run':
@@ -174,7 +207,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
174
207
  // A verb's own refusal is its one line; anything else is the
175
208
  // plugin's crash, named as every other stage names one.
176
209
  if (isUserError(err)) throw err
177
- const message = err instanceof Error ? err.message : String(err)
210
+ const message = thrownMessage(err)
178
211
  throw new UserError(
179
212
  `plugin '${resolved.plugin.name}' failed in command '${command}': ${message}`,
180
213
  )
@@ -201,10 +234,12 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
201
234
  text: string,
202
235
  code = 'VX_E_UNKNOWN_COMMAND',
203
236
  tone: 'error' | 'info' = 'error',
237
+ json = text.split('\n')[0]!,
204
238
  ): number => {
205
239
  sayNotice(tone, 'vx', text.replace(/^vx: /, '').trimEnd())
206
- if (wantsJson(rest))
207
- process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
240
+ // `vx --format json` puts the flag where the verb goes.
241
+ if (wantsJson([command, ...rest]))
242
+ process.stdout.write(errorDocument(code, maskedLine(json)))
208
243
  return 1
209
244
  }
210
245
  const loadNote =
@@ -232,12 +267,13 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
232
267
  // provided by PLUGINS (declared in .vx/workspace.ts), never by core.
233
268
  // We keep this neutral hint for the common muscle-memory verbs, but
234
269
  // core names no specific plugin package: any package can provide
235
- // these.
270
+ // these. The notice prints a line per fact, so each line is a
271
+ // whole sentence.
236
272
  return unknown(
237
- `vx: '${command}' is not a vx core command.\n` +
238
- ` vx core runs tasks in-process. A dashboard, remote cache, and\n` +
239
- ` distributed execution come from plugins — not core. See the plugin\n` +
240
- ` guide: https://vznjs.github.io/vx/guides/plugins/\n`,
273
+ `vx: \`${command}\` is not a vx core command.\n` +
274
+ ` vx core runs tasks in-process.\n` +
275
+ ` A dashboard, remote cache and distributed execution come from plugins, not core.\n` +
276
+ ` See the plugin guide: https://vznjs.github.io/vx/docs/plugins/\n`,
241
277
  )
242
278
  }
243
279
  // One line, as a verb's own unknown flag or subcommand gets; the full
@@ -259,9 +295,19 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
259
295
  'VX_E_USAGE',
260
296
  )
261
297
  }
298
+ // A workspace that does not load is the refusal to fix first: the
299
+ // verb may be its own plugin's.
300
+ if (resolved !== null && 'loadError' in resolved) {
301
+ return unknown(
302
+ `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n`,
303
+ 'VX_E_CONFIG',
304
+ 'error',
305
+ `vx: unknown command: ${command}; .vx/workspace failed to load: ${resolved.loadError}`,
306
+ )
307
+ }
262
308
  return unknown(
263
- `vx: unknown command: ${command}${guess}${loadNote} (see \`vx help\`)\n` +
264
- (guess === '' && loadNote === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
309
+ `vx: unknown command: ${command}${guess} (see \`vx help\`)\n` +
310
+ (guess === '' ? verbSourceNote(resolved, declaredVerbs) : ''),
265
311
  )
266
312
  }
267
313
  }
@@ -298,6 +344,6 @@ function verbSourceNote(resolved: unknown, declaredVerbs: readonly string[]): st
298
344
  if (declaredVerbs.length > 0) {
299
345
  return ` This workspace's plugins declare: ${[...declaredVerbs].sort().join(', ')}.\n`
300
346
  }
301
- const here = resolved === null ? 'there is no workspace here' : 'this workspace declares none'
347
+ const here = resolved === null ? 'there is no workspace here' : 'no plugin here adds one'
302
348
  return ` A plugin declared in .vx/workspace.ts can add verbs; ${here}.\n`
303
349
  }
package/src/cli/info.ts CHANGED
@@ -113,20 +113,24 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
113
113
  // the hits are a share of.
114
114
  [
115
115
  'task runs (24h)',
116
- `${f.runs24h} (${f.hits24h} cache hits` +
116
+ `${f.runs24h} (${f.hits24h} cache hit${f.hits24h === 1 ? '' : 's'}` +
117
117
  (f.hits24h > 0
118
118
  ? `: ${f.hits24h - f.restored24h} up-to-date, ${f.restored24h} restored)`
119
119
  : ')'),
120
120
  ],
121
121
  ['flaky tasks', describeFlakyTasks(f.flakyTasks)],
122
122
  ['sandbox', describeSandbox(f.sandbox)],
123
- ['projects lock', f.lockfile ? 'yes' : 'no'],
123
+ [
124
+ 'projects lock',
125
+ typeof f.lockError === 'string' ? `refused: ${f.lockError}` : f.lockfile ? 'yes' : 'no',
126
+ ],
124
127
  ]
125
128
  return ui.report('vx info', rows).lines(colors).join('\n')
126
129
  }
127
130
 
128
- /** `812 (1.2 GB)`. */
131
+ /** `812 (1.2 GB)`, or why the index cannot be read and its fix. */
129
132
  function describeEntries(f: InfoFacts): string {
133
+ if (f.cacheIndexError !== null) return `unreadable: ${f.cacheIndexError}`
130
134
  return `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`
131
135
  }
132
136
 
@@ -139,7 +143,7 @@ function describeSettings(settings: InfoFacts['settings']): string {
139
143
  .filter(([, s]) => s.source !== 'default')
140
144
  .map(
141
145
  ([path, s]) =>
142
- `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'})`,
146
+ `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
143
147
  )
144
148
  .join(', ')
145
149
  }
@@ -169,9 +173,11 @@ function describeSandbox(s: InfoFacts['sandbox']): string {
169
173
  /** `4 — the CPU count`, `2 — cgroup CPU quota 2 of 8 cores`, `8 — .vx/workspace.ts (4 cores)`. */
170
174
  export function describeWorkers(w: InfoFacts['workers']): string {
171
175
  const quota = w.cpuQuota === null ? '' : `, cgroup CPU quota ${trimCores(w.cpuQuota)}`
172
- if (w.source === 'workspace') return `${w.count} — .vx/workspace.ts (${w.cores} cores${quota})`
176
+ const cores = `${w.cores} core${w.cores === 1 ? '' : 's'}`
177
+ if (w.source === 'workspace') return `${w.count} — .vx/workspace.ts (${cores}${quota})`
178
+ if (w.source === 'env') return `${w.count} — ${envName('run.concurrency')} (${cores}${quota})`
173
179
  if (w.source === 'cgroup')
174
- return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${w.cores} cores`
180
+ return `${w.count} — cgroup CPU quota ${trimCores(w.cpuQuota ?? w.count)} of ${cores}`
175
181
  return `${w.count} — the CPU count${quota}`
176
182
  }
177
183
 
package/src/cli/init.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
2
2
  // It installs vx globally and, where a package.json is, as its devDependency
3
- // (M, 2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
3
+ // (2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
4
4
  // and installs what it writes (`migrate`); any other gets the workspace file
5
5
  // and an empty root project, then the lock (`starter`).
6
- // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
6
+ // `vx init --plugin <hook>` writes a runnable plugin for one hook and its
7
7
  // test instead (plugin-templates.ts, the examples the gate runs).
8
8
 
9
- import { existsSync } from 'node:fs'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
10
  import { mkdir, unlink } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { gitTop, workspaceFileWritten } from '../handoff.js'
@@ -22,10 +22,23 @@ import {
22
22
  type ManagedInstall,
23
23
  } from './upgrade.js'
24
24
  import { VERSION } from '../version.js'
25
- import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
25
+ import {
26
+ detectColors,
27
+ DOT_VX_GITATTRIBUTES,
28
+ DOT_VX_GITIGNORE,
29
+ paint,
30
+ refuseSymlink,
31
+ sayNotice,
32
+ SUCCESS,
33
+ ui,
34
+ UserError,
35
+ writeNoFollow,
36
+ } from '../util/index.js'
26
37
  import {
27
38
  findWorkspaceRoot,
28
39
  PROJECT_CONFIG_FILENAMES,
40
+ renderWorkspace,
41
+ starterProject,
29
42
  WORKSPACE_CONFIG_FILENAMES,
30
43
  } from '../workspace/index.js'
31
44
 
@@ -34,7 +47,7 @@ interface InitArgs {
34
47
  force: boolean
35
48
  /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
36
49
  mjs: boolean
37
- /** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
50
+ /** `--plugin <hook>`: scaffold that hook's plugin instead of the workspace. */
38
51
  plugin?: string
39
52
  /** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
40
53
  mode?: 'native' | 'keep'
@@ -53,7 +66,16 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
53
66
  if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
54
67
  return {
55
68
  ...out,
56
- error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
69
+ error:
70
+ seam === undefined || seam === ''
71
+ ? `--plugin requires a value: one of ${seams}${seeHelp('init')}`
72
+ : `--plugin must be one of ${seams} (got ${seam})${seeHelp('init')}`,
73
+ }
74
+ }
75
+ if (out.plugin !== undefined && out.plugin !== seam) {
76
+ return {
77
+ ...out,
78
+ error: `--plugin takes one hook; run vx init --plugin once per hook${seeHelp('init')}`,
57
79
  }
58
80
  }
59
81
  out.plugin = seam
@@ -116,16 +138,47 @@ export async function initCmd(args: readonly string[]): Promise<number> {
116
138
  if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
117
139
  return migrate(dirs, parsed)
118
140
  }
119
- // The topmost package.json: a monorepo's root, wherever below it init ran.
120
- const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
141
+ const pkgRoot = packageRoot(dirs, top !== null)
121
142
  return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
122
143
  }
123
144
 
145
+ /**
146
+ * Inside git, the topmost package.json: a monorepo's root, wherever below
147
+ * it init ran. Outside git `dirs` reach `/`, and the topmost took a stray
148
+ * package.json in the home directory (2026-10-28); there it is the nearest
149
+ * one, or the outermost above it that declares a workspace.
150
+ */
151
+ function packageRoot(dirs: readonly string[], inGit: boolean): string | null {
152
+ const has = (d: string, f: string): boolean => existsSync(path.join(d, f))
153
+ if (inGit) return dirs.findLast((d) => has(d, 'package.json')) ?? null
154
+ const at = dirs.findIndex((d) => has(d, 'package.json'))
155
+ if (at === -1) return null
156
+ const outer = dirs
157
+ .slice(at + 1)
158
+ .findLast(
159
+ (d) => has(d, 'package.json') && (has(d, 'pnpm-workspace.yaml') || declaresWorkspaces(d)),
160
+ )
161
+ return outer ?? dirs[at]!
162
+ }
163
+
164
+ function declaresWorkspaces(dir: string): boolean {
165
+ try {
166
+ const pj = JSON.parse(
167
+ readFileSync(path.join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''),
168
+ ) as {
169
+ workspaces?: unknown
170
+ }
171
+ return pj.workspaces !== undefined
172
+ } catch {
173
+ return false
174
+ }
175
+ }
176
+
124
177
  /** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
125
178
  const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
126
179
 
127
180
  /**
128
- * The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
181
+ * The installs `vx init` runs (2026-10-20): vx at `version` globally,
129
182
  * with the manager that started init, so a bare `vx` is on PATH, unless
130
183
  * the global vx already answers `version`; and, where `pkgRoot` has a
131
184
  * package.json, as its devDependency with the repo's own manager.
@@ -140,7 +193,9 @@ export function initInstalls(
140
193
  ): { argv: string[]; cwd: string | null }[] {
141
194
  const out: { argv: string[]; cwd: string | null }[] = []
142
195
  if (globalAt !== version) {
143
- const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'bun'
196
+ // Yarn 2+ has no `yarn global`: Bun, which vx needs anyway, as with no runner.
197
+ const ua = /^(npm|pnpm|yarn|bun)\/(\d+)/.exec(userAgent ?? '')
198
+ const runner = ua === null || (ua[1] === 'yarn' && Number(ua[2]) >= 2) ? 'bun' : ua[1]!
144
199
  const manager = runner as ManagedInstall['manager']
145
200
  out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
146
201
  }
@@ -169,6 +224,21 @@ function globalVersion(): string | undefined {
169
224
  return undefined
170
225
  }
171
226
 
227
+ /**
228
+ * The package a manager's output says the registry answered 404 for, as
229
+ * `name@version`: a release's platform tarball stays unserved for minutes
230
+ * after publish (v0.0.646, 2026-10-23). bun: `GET <url> - 404`; npm:
231
+ * `404 Not Found - GET <url>`; pnpm: `GET <url>: Not Found - 404`.
232
+ */
233
+ export function notServed(output: string): string | undefined {
234
+ for (const line of output.split('\n')) {
235
+ if (!line.includes('404')) continue
236
+ const m = /https?:\/\/\S+?\/((?:@[^/\s]+\/)?[^/\s]+)\/-\/[^/\s]+?-(\d[^/\s]*?)\.tgz/.exec(line)
237
+ if (m !== null) return `${decodeURIComponent(m[1]!)}@${m[2]}`
238
+ }
239
+ return undefined
240
+ }
241
+
172
242
  /**
173
243
  * Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
174
244
  * had) installs nothing: the release would replace the code under test.
@@ -200,22 +270,62 @@ async function install(
200
270
  'out',
201
271
  ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
202
272
  )
203
- const proc = Bun.spawnSync({
273
+ const proc = Bun.spawn({
204
274
  cmd: [bin, ...s.argv.slice(1)],
205
275
  cwd: s.cwd ?? process.cwd(),
206
- stdio: ['inherit', 'inherit', 'inherit'],
276
+ stdio: ['inherit', 'inherit', 'pipe'],
207
277
  })
208
- if (proc.exitCode !== 0) {
209
- throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
278
+ // Shown as it comes, and kept: a registry 404 is the cause to name.
279
+ let said = ''
280
+ const decoder = new TextDecoder()
281
+ for await (const chunk of proc.stderr) {
282
+ process.stderr.write(chunk)
283
+ said += decoder.decode(chunk, { stream: true })
284
+ }
285
+ const code = await proc.exited
286
+ if (code !== 0) {
287
+ const missing = notServed(said)
288
+ throw new UserError(
289
+ missing === undefined
290
+ ? `vx init: ${s.argv.join(' ')} failed (exit ${code})`
291
+ : `vx init: the npm registry does not serve ${missing} yet (404), as a release can take minutes to reach it; run bunx @vzn/vx init again`,
292
+ )
210
293
  }
211
294
  }
212
295
  return steps.map((s) => s.argv.join(' '))
213
296
  }
214
297
 
298
+ const PLUGINS_MANIFEST = `${JSON.stringify({ name: 'workspace-plugins', private: true }, null, 2)}\n`
299
+
300
+ /** Whether the nearest package.json above `dir` has a name, as `definePlugin` needs. */
301
+ function ownerNamed(dir: string): boolean {
302
+ for (let d = dir; ; d = path.dirname(d)) {
303
+ let text: string
304
+ try {
305
+ text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^/, '')
306
+ } catch {
307
+ if (path.dirname(d) === d) return false
308
+ continue
309
+ }
310
+ try {
311
+ const name = (JSON.parse(text) as { name?: unknown } | null)?.name
312
+ return typeof name === 'string' && name !== ''
313
+ } catch {
314
+ return false
315
+ }
316
+ }
317
+ }
318
+
215
319
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
216
320
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
217
321
  const template = PLUGIN_TEMPLATES[seam]!
322
+ // `definePlugin` names the plugin by the nearest package.json, and a
323
+ // monorepo root often has no name: the plugin refused to load.
324
+ const manifest: [string, string][] = ownerNamed(path.join(root, 'plugins'))
325
+ ? []
326
+ : [[path.join('plugins', 'package.json'), PLUGINS_MANIFEST]]
218
327
  const files = [
328
+ ...manifest,
219
329
  [path.join('plugins', `${seam}.ts`), template.plugin],
220
330
  [path.join('plugins', `${seam}.test.ts`), template.test],
221
331
  ] as const
@@ -228,16 +338,26 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
228
338
  }
229
339
  }
230
340
  if (!args.dry) {
231
- await mkdir(path.join(root, 'plugins'), { recursive: true })
341
+ await mkdir(path.join(root, 'plugins'), { recursive: true }).catch(
342
+ (err: NodeJS.ErrnoException) => {
343
+ if (err.code !== 'EEXIST' && err.code !== 'ENOTDIR') throw err
344
+ throw new UserError(
345
+ `vx init: cannot write ${files[0][0]}: plugins is a file, not a directory; move it and re-run`,
346
+ )
347
+ },
348
+ )
232
349
  for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
233
350
  }
234
351
  const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
235
352
  const workspaceFile =
236
353
  WORKSPACE_CONFIG_FILENAMES.find((n) => existsSync(path.join(root, n))) ?? '.vx/workspace.ts'
354
+ const c = detectColors(process.stdout)
355
+ const dim = (t: string): string => paint('', t, c, { dim: true })
356
+ const bold = (t: string): string => paint('', t, c, { bold: true })
237
357
  process.stdout.write(
238
- `${args.dry ? 'would write' : 'wrote'} ${files.map(([rel]) => rel).join(' and ')}\n` +
239
- `Declare it in ${workspaceFile}: import { ${factory} } from '../plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
240
- `Test it: bun test plugins/${seam}.test.ts (needs @vzn/vx installed)\n`,
358
+ `${paint(SUCCESS, args.dry ? 'would write' : 'wrote', c, { bold: true })} ${files.map(([rel]) => bold(rel)).join(' and ')}\n` +
359
+ `${dim('Declare it in')} ${workspaceFile}${dim(':')} ${bold(`import { ${factory} } from '../plugins/${seam}.ts'`)}${dim(', then')} ${bold(`plugins: [${factory}(…)]`)}\n` +
360
+ `${dim('Test it:')} ${bold(`bun test plugins/${seam}.test.ts`)} ${dim('(needs @vzn/vx installed)')}\n`,
241
361
  )
242
362
  return 0
243
363
  }
@@ -257,13 +377,30 @@ function lockfilePlugin(pkgRoot: string | null): 'pnpm' | 'bun' | 'yarn' | 'npm'
257
377
  return null
258
378
  }
259
379
 
260
- function workspaceSource(plugin: string | null): string {
261
- if (plugin === null)
262
- return "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
263
- return `import { defineWorkspace } from '@vzn/vx'\nimport { ${plugin} } from '@vzn/vx-lockfile'\n\nexport default defineWorkspace({ plugins: [${plugin}()] })\n`
380
+ /**
381
+ * Whether `pkgRoot` has package.json scripts to map: its own, or a
382
+ * workspace's members (`workspaces`, `pnpm-workspace.yaml`). Core maps none
383
+ * (2026-10-20), so `next` names the tool that does.
384
+ */
385
+ function hasScripts(pkgRoot: string | null): boolean {
386
+ if (pkgRoot === null) return false
387
+ if (existsSync(path.join(pkgRoot, 'pnpm-workspace.yaml'))) return true
388
+ try {
389
+ // A byte-order mark is stripped, as npm strips it.
390
+ const pj = JSON.parse(
391
+ readFileSync(path.join(pkgRoot, 'package.json'), 'utf8').replace(/^/, ''),
392
+ ) as {
393
+ scripts?: unknown
394
+ workspaces?: unknown
395
+ }
396
+ return (
397
+ pj.workspaces !== undefined ||
398
+ (typeof pj.scripts === 'object' && pj.scripts !== null && Object.keys(pj.scripts).length > 0)
399
+ )
400
+ } catch {
401
+ return false
402
+ }
264
403
  }
265
- const PROJECT =
266
- "import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
267
404
 
268
405
  /**
269
406
  * No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
@@ -280,6 +417,7 @@ async function starter(
280
417
  if (args.mode !== undefined) {
281
418
  throw new UserError(
282
419
  `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
420
+ 'VX_E_USAGE',
283
421
  )
284
422
  }
285
423
  // A source checkout installs nothing, so it declares the plugin only where one is.
@@ -298,8 +436,12 @@ async function starter(
298
436
  const kept: string[] = []
299
437
  const replaced: string[] = []
300
438
  for (const [names, name, contents] of [
301
- [WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, workspaceSource(plugin)],
302
- [PROJECT_CONFIG_FILENAMES, config, PROJECT],
439
+ [
440
+ WORKSPACE_CONFIG_FILENAMES,
441
+ `.vx/workspace.${ext}`,
442
+ renderWorkspace(ext, plugin === null ? [] : [{ pkg: '@vzn/vx-lockfile', factory: plugin }]),
443
+ ],
444
+ [PROJECT_CONFIG_FILENAMES, config, starterProject(ext)],
303
445
  ] as const) {
304
446
  const existing = names.find((n) => existsSync(path.join(root, n)))
305
447
  if (existing !== undefined && !args.force) kept.push(existing)
@@ -315,7 +457,8 @@ async function starter(
315
457
  ] as const) {
316
458
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
317
459
  }
318
- const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
460
+ const declare = `declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
461
+ const next = `${noGit ? 'git init, ' : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
319
462
  if (args.json === true) {
320
463
  process.stdout.write(
321
464
  `${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
@@ -323,19 +466,30 @@ async function starter(
323
466
  return 0
324
467
  }
325
468
  const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
469
+ // The files are root-relative: a root above the cwd is named, or they read as written here.
470
+ if (root !== process.cwd()) rows.push(['root', root])
326
471
  if (args.dry) rows.push(['dry run', 'nothing written'])
327
472
  if (installs.length > 0) rows.push(['install', ...installs])
328
473
  if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
329
474
  if (kept.length > 0) rows.push(['kept', ...kept])
330
475
  if (replaced.length > 0) rows.push(['replaced', ...replaced])
331
476
  if (args.dry) {
332
- for (const f of files) process.stdout.write(`── ${f.path} ──\n${f.contents}\n`)
477
+ for (const f of files) ui.print('out', ui.excerpt(f.path, f.contents))
333
478
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
334
479
  return 0
335
480
  }
481
+ for (const f of files) refuseSymlink(path.join(root, f.path))
336
482
  const ran = await install(pkgRoot, false, plugins)
337
483
  if (ran.length > 0) rows.push(['installed', ...ran])
338
- for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
484
+ for (const f of files) {
485
+ await writeNoFollow(path.join(root, f.path), f.contents).catch((err: NodeJS.ErrnoException) => {
486
+ // writeNoFollow's mkdir of a parent that is a file says EEXIST.
487
+ if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
488
+ throw new UserError(
489
+ `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
490
+ )
491
+ })
492
+ }
339
493
  for (const f of replaced) await unlink(path.join(root, f))
340
494
  // The lock that follows loads the workspace file just written.
341
495
  workspaceFileWritten()
@@ -345,14 +499,15 @@ async function starter(
345
499
  lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
346
500
  ])
347
501
  ui.print('out', ui.report(`vx init ${VERSION}`, rows))
348
- return 0
502
+ // A run reads only the lock: init without one is not done, as vx-migrate exits.
503
+ return lock.code === 0 ? 0 : 1
349
504
  }
350
505
 
351
506
  /**
352
507
  * A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
353
508
  * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
354
509
  * at its own version, so init always lands on latest with all of them
355
- * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
510
+ * together, as `vx upgrade` moves them (2026-10-16: an installed 0.0.637
356
511
  * pinned core back from 0.0.639). It finds the root itself, asks a
357
512
  * terminal native or keep in a Turbo or Nx repo, and installs what it writes.
358
513
  */