@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/cli/select.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  maskedLine,
33
33
  nearest,
34
34
  UserError,
35
+ sayNotice,
35
36
  } from '../util/index.js'
36
37
  import {
37
38
  claimedAffected,
@@ -434,8 +435,10 @@ export async function resolveFilters(
434
435
  // Something matched, so the run proceeds; a pattern that matched nothing
435
436
  // alongside it is still worth a line — it is probably a typo.
436
437
  for (const f of unmatched)
437
- process.stderr.write(
438
- `vx: filter "${f}" matched no projects${didYouMean([f], projects, tags)}\n`,
438
+ sayNotice(
439
+ 'warn',
440
+ 'vx run',
441
+ `filter "${f}" matched no projects${didYouMean([f], projects, tags)}`,
439
442
  )
440
443
  // Each include is a union: what `--filter other` selected runs its tasks
441
444
  // whether or not the `--affected` diff reaches them, and an exclude
@@ -511,14 +514,18 @@ export async function pickTask(
511
514
  }
512
515
  }
513
516
  if (entries.length === 0 && only !== undefined) {
514
- process.stderr.write(
515
- `vx run: no tasks declared in the selected projects (${[...only].sort().join(', ')})\n`,
517
+ sayNotice(
518
+ 'error',
519
+ 'vx run',
520
+ `no tasks declared in the selected projects (${[...only].sort().join(', ')})`,
516
521
  )
517
522
  return null
518
523
  }
519
524
  if (entries.length === 0) {
520
- process.stderr.write(
521
- 'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts, then `vx lock`\n',
525
+ sayNotice(
526
+ 'error',
527
+ 'vx run',
528
+ 'no tasks declared in any project. Declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts, then `vx lock`',
522
529
  )
523
530
  return null
524
531
  }
@@ -562,13 +569,15 @@ export async function pickTask(
562
569
  if (!(err instanceof Error) || err.name !== 'AbortError') throw err
563
570
  if (interrupted) return 'interrupted'
564
571
  out.write('\n')
565
- process.stderr.write(`vx run: no task picked\n`)
572
+ sayNotice('info', 'vx run', 'no task picked')
566
573
  return null
567
574
  }
568
575
  const n = Number(answer)
569
576
  if (!Number.isInteger(n) || n < 1 || n > entries.length) {
570
- process.stderr.write(
571
- `vx run: invalid selection: ${answer} (type a number from 1 to ${entries.length})\n`,
577
+ sayNotice(
578
+ 'error',
579
+ 'vx run',
580
+ `invalid selection: ${answer} (type a number from 1 to ${entries.length})`,
572
581
  )
573
582
  return null
574
583
  }
package/src/cli/show.ts CHANGED
@@ -11,7 +11,17 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
- import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
14
+ import {
15
+ type ColorSupport,
16
+ detectColors,
17
+ listed,
18
+ nearMatches,
19
+ relPosix,
20
+ secretMask,
21
+ UserError,
22
+ sayNotice,
23
+ ui,
24
+ } from '../util/index.js'
15
25
  import { affectedFilterFor, resolveFilters } from './select.js'
16
26
  import {
17
27
  discoverCliProjects,
@@ -149,7 +159,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
149
159
  }
150
160
  const r = await resolveFilters(process.cwd(), raw, {}, affected)
151
161
  if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
152
- if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
162
+ if ('empty' in r) sayNotice('info', 'vx show', r.empty)
153
163
  selected = new Set('empty' in r ? [] : r.names)
154
164
  if ('names' in r && r.affected !== undefined)
155
165
  byDiff = { changes: r.affected, outright: r.outright }
@@ -159,8 +169,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
159
169
  const projects = await loadCliProjects(root, metas, scope, { closure: true })
160
170
 
161
171
  if (parsed.target === undefined) {
172
+ if (metas.length === 0)
173
+ sayNotice(
174
+ 'info',
175
+ 'vx show',
176
+ 'no projects: no directory holds a vx.config — run `vx init` to write them, then `vx lock`',
177
+ )
162
178
  const shown = metas.filter((m) => inSelection(m.name))
163
- process.stdout.write(renderList(root, shown, projects, parsed.format))
179
+ process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
164
180
  return 0
165
181
  }
166
182
 
@@ -204,7 +220,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
204
220
  )
205
221
  shown = shown.filter((p) => kept.has(p.name))
206
222
  }
207
- process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
223
+ process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
208
224
  return 0
209
225
  }
210
226
 
@@ -215,7 +231,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
215
231
 
216
232
  if (taskName === undefined) {
217
233
  process.stdout.write(
218
- renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format),
234
+ renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
219
235
  )
220
236
  return 0
221
237
  }
@@ -227,7 +243,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
227
243
  'VX_E_UNKNOWN_TASK',
228
244
  )
229
245
  }
230
- process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
246
+ process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format, colorsOut()))
231
247
  return 0
232
248
  }
233
249
 
@@ -284,11 +300,20 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
284
300
  }
285
301
  }
286
302
 
303
+ const colorsOut = (): ColorSupport => detectColors(process.stdout)
304
+ const PLAIN: ColorSupport = { enabled: false }
305
+
306
+ /** One block of several, with no blank line between them. */
307
+ const joined = (blocks: readonly ui.Block[]): ui.Block => ({
308
+ lines: (c) => blocks.flatMap((b) => b.lines(c)),
309
+ })
310
+
287
311
  function renderList(
288
312
  root: string,
289
313
  metas: readonly ProjectMeta[],
290
314
  projects: ReadonlyMap<string, ProjectEntry>,
291
315
  format: 'pretty' | 'json',
316
+ colors: ColorSupport = PLAIN,
292
317
  ): string {
293
318
  const rows = metas.map((meta) => {
294
319
  const tasks = projects.get(meta.name)?.config.tasks ?? {}
@@ -309,9 +334,8 @@ function renderList(
309
334
  )}\n`
310
335
  }
311
336
  if (rows.length === 0) return ''
312
- const nameW = Math.max(...rows.map((r) => r.name.length))
313
- const dirW = Math.max(...rows.map((r) => r.dir.length))
314
- const lines = rows.map((r) => {
337
+ const anyTags = rows.some((r) => r.tags.length > 0)
338
+ const cells = rows.map((r) => {
315
339
  const n = r.tasks.length
316
340
  const count = `${n} task${n === 1 ? '' : 's'}`
317
341
  // A package with no config file has only tasks a plugin gave it, or the
@@ -319,14 +343,14 @@ function renderList(
319
343
  const tasks = r.configured
320
344
  ? count
321
345
  : n === 0
322
- ? '(no vx config)'
346
+ ? 'no vx config'
323
347
  : r.onlyDefault
324
- ? `${count} (no vx config; default build)`
325
- : `${count} (no vx config; from plugins)`
326
- const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
327
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
348
+ ? `${count}, default build (no vx config)`
349
+ : `${count} from plugins (no vx config)`
350
+ return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
328
351
  })
329
- return `${lines.join('\n')}\n`
352
+ const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
353
+ return ui.render([ui.table(columns, cells)], colors)
330
354
  }
331
355
 
332
356
  function renderProject(
@@ -335,6 +359,7 @@ function renderProject(
335
359
  config: ProjectConfig | null,
336
360
  hasConfigFile: boolean,
337
361
  format: 'pretty' | 'json',
362
+ colors: ColorSupport = PLAIN,
338
363
  ): string {
339
364
  if (format === 'json') {
340
365
  // Round-trip so the printed object is exactly the JSON form of the
@@ -344,14 +369,17 @@ function renderProject(
344
369
  }
345
370
  const tags = config?.tags ?? []
346
371
  const tasks = Object.entries(config?.tasks ?? {})
347
- const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}${
372
+ const head: ui.FieldRow[] = [
373
+ ['dir', dir],
374
+ ...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
348
375
  // A config-less package a dependant reaches has the default `build` (X-9).
349
- hasConfigFile || tasks.length === 0 ? '' : '\n (no vx config)'
350
- }`
351
- if (tasks.length === 0)
352
- return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
353
- const blocks = tasks.map(([taskName, task]) => taskBlock(taskName, task))
354
- return `${head}\n\n${blocks.join('\n')}`
376
+ ...(hasConfigFile ? [] : [['config', 'none'] as const]),
377
+ ...(tasks.length === 0 && hasConfigFile ? [['tasks', 'none declared'] as const] : []),
378
+ ]
379
+ const blocks = tasks.map(([taskName, task]) =>
380
+ joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
381
+ )
382
+ return ui.render([ui.report(name, head), ...blocks], colors)
355
383
  }
356
384
 
357
385
  function renderTask(
@@ -360,12 +388,13 @@ function renderTask(
360
388
  taskName: string,
361
389
  task: TaskConfig,
362
390
  format: 'pretty' | 'json',
391
+ colors: ColorSupport = PLAIN,
363
392
  ): string {
364
393
  if (format === 'json') {
365
394
  const obj = { name, dir, task: taskName, config: shownTask(task) }
366
395
  return `${JSON.stringify(obj)}\n`
367
396
  }
368
- return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
397
+ return ui.render([taskReport(name, dir, taskName, task)], colors)
369
398
  }
370
399
 
371
400
  function renderTaskAcross(
@@ -373,6 +402,7 @@ function renderTaskAcross(
373
402
  declaring: readonly ProjectEntry[],
374
403
  taskName: string,
375
404
  format: 'pretty' | 'json',
405
+ colors: ColorSupport,
376
406
  ): string {
377
407
  if (format === 'json') {
378
408
  const list = declaring.map((p) => ({
@@ -383,18 +413,28 @@ function renderTaskAcross(
383
413
  }))
384
414
  return `${JSON.stringify(list)}\n`
385
415
  }
386
- return declaring
387
- .map(
388
- (p) =>
389
- `${p.name} — ${relPosix(root, p.dir) || '.'}\n\n${taskBlock(taskName, p.config.tasks![taskName]!)}`,
390
- )
391
- .join('\n')
416
+ if (declaring.length === 0) return ''
417
+ return ui.render(
418
+ [
419
+ joined(
420
+ declaring.map((p) =>
421
+ taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
422
+ ),
423
+ ),
424
+ ],
425
+ colors,
426
+ )
427
+ }
428
+
429
+ /** `─ app#build ───`, the project's dir, then every field the run reads. */
430
+ function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
431
+ return ui.report(`${name}#${taskName}`, [['dir', dir], ...taskRows(task)])
392
432
  }
393
433
 
394
434
  /** Every field the run reads, in the order the schema declares them. */
395
- function taskBlock(taskName: string, raw: TaskConfig): string {
435
+ function taskRows(raw: TaskConfig): ui.FieldRow[] {
396
436
  const task = shownTask(raw)
397
- const rows: [string, string][] = []
437
+ const rows: ui.FieldRow[] = []
398
438
  const list = (xs: readonly string[] | undefined): string | undefined =>
399
439
  xs === undefined ? undefined : xs.join(', ')
400
440
  const add = (label: string, value: string | number | boolean | undefined): void => {
@@ -437,13 +477,5 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
437
477
  add('outputs.files', list(cache.outputs.files))
438
478
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
439
479
  }
440
- const labelW = Math.max(...rows.map(([label]) => label.length))
441
- // A multi-line value (a generated `vx_script` wrapper, a heredoc)
442
- // continues under its first line, not at column 0 where it reads as
443
- // the next task's header.
444
- const indent = `\n${' '.repeat(labelW + 4)}`
445
- const body = rows.map(
446
- ([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
447
- )
448
- return `${taskName}\n${body.join('\n')}\n`
480
+ return rows
449
481
  }
@@ -21,7 +21,7 @@ export async function taskVerbHint(
21
21
  cwd: string,
22
22
  typo = false,
23
23
  ): Promise<string | null> {
24
- if (command.includes('#')) return `\`${command}\` is a task: vx run ${command}`
24
+ if (command.includes('#')) return `\`${command}\` is a task (did you mean vx run ${command}?)`
25
25
  const projects = await workspaceProjects(cwd)
26
26
  if (projects === null) return null
27
27
  const tasks = new Set<string>()
@@ -40,8 +40,8 @@ export async function taskVerbHint(
40
40
  ? `vx run ${task} --all`
41
41
  : `vx run ${task}`
42
42
  return task === command
43
- ? `\`${command}\` is a task here, not a command: ${run}`
44
- : `unknown command: ${command}; did you mean the task \`${task}\`? ${run}`
43
+ ? `\`${command}\` is a task here, not a command (did you mean ${run}?)`
44
+ : `unknown command: ${command}, a typo of the task \`${task}\` (did you mean ${run}?)`
45
45
  }
46
46
 
47
47
  /**
@@ -54,7 +54,7 @@ export async function nxTargetHint(tasks: readonly string[], cwd: string): Promi
54
54
  if (projects === null) return null
55
55
  const specs = tasks.map((t) => nxProjectTarget(t, projects))
56
56
  if (specs.some((s) => s === undefined)) return null
57
- return `\`${tasks.join(' ')}\` is Nx's project:target: vx run ${specs.join(' ')}`
57
+ return `\`${tasks.join(' ')}\` is Nx's project:target (did you mean vx run ${specs.join(' ')}?)`
58
58
  }
59
59
 
60
60
  /**
@@ -10,6 +10,8 @@
10
10
  // package manager (npm, pnpm, bun, yarn; global or a project's
11
11
  // dependency) is upgraded by that package manager, never by a rename over
12
12
  // a file it owns. vx never installs Node or a package manager.
13
+ // Handed a call by a global vx, the project's vx moves that global too,
14
+ // to the same version (`upgradeGlobal`).
13
15
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
14
16
  // deno upgrade — "update" is what package managers do to indexes.
15
17
 
@@ -18,7 +20,7 @@ import { createPublicKey, verify } from 'node:crypto'
18
20
  import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
19
21
  import path from 'node:path'
20
22
  import { flagHint, seeHelp } from './help.js'
21
- import { UserError } from '../util/index.js'
23
+ import { UserError, sayNotice, ui } from '../util/index.js'
22
24
  import { VERSION } from '../version.js'
23
25
 
24
26
  const REGISTRY = 'https://registry.npmjs.org'
@@ -290,17 +292,19 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
290
292
  /**
291
293
  * The binary of `pkg@spec`, verified: the registry document's signature,
292
294
  * then the tarball's integrity, then the extraction, each refusing before
293
- * the next step runs. Null when the release is this vx's own version;
294
- * `announce` hears the version before the download starts.
295
+ * the next step runs. Null when the release is `current`, the version of
296
+ * the vx being replaced; `announce` hears the version before the download
297
+ * starts.
295
298
  */
296
299
  export async function releaseBinary(
297
300
  pkg: string,
298
301
  spec: string,
299
302
  announce: (version: string) => void,
300
303
  key: SigningKey = NPM_SIGNING_KEY,
304
+ current = VERSION,
301
305
  ): Promise<Uint8Array | null> {
302
306
  const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
303
- if (release.version === VERSION) return null
307
+ if (release.version === current) return null
304
308
  announce(release.version)
305
309
  return binaryFromTarball(await downloadVerified(release))
306
310
  }
@@ -518,22 +522,33 @@ async function targetVersion(tag: string | undefined): Promise<string> {
518
522
  return v
519
523
  }
520
524
 
521
- /** Run the package manager that owns this vx; vx never installs one. */
522
- async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
523
- const version = await targetVersion(tag)
524
- if (version === VERSION) {
525
- process.stdout.write(`vx upgrade: already at ${VERSION} — nothing to do\n`)
526
- return 0
525
+ /**
526
+ * Run the package manager that owns a vx at `current` to bring it to
527
+ * `version`; vx never installs one. `step` names the install when a run
528
+ * moves two (`project `, `global `).
529
+ */
530
+ async function upgradeManaged(
531
+ install: ManagedInstall,
532
+ version: string,
533
+ current = VERSION,
534
+ step = '',
535
+ ): Promise<void> {
536
+ if (version === current) {
537
+ ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
538
+ return
527
539
  }
528
540
  const argv = upgradeArgv(install, version)
529
- const bin = Bun.which(argv[0]!)
541
+ // PATH as it is now: `Bun.which` alone reads the one the process started with.
542
+ const bin = Bun.which(argv[0]!, { PATH: process.env['PATH'] ?? '' })
530
543
  if (bin === null) {
531
544
  throw new UserError(
532
545
  `vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
533
546
  )
534
547
  }
535
- process.stdout.write(
536
- `vx upgrade: ${VERSION} → ${version}: running ${argv.join(' ')} (in ${install.cwd})\n`,
548
+ ui.print(
549
+ 'out',
550
+ ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
551
+ ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
537
552
  )
538
553
  const proc = Bun.spawnSync({
539
554
  cmd: [bin, ...argv.slice(1)],
@@ -545,14 +560,99 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
545
560
  `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
546
561
  )
547
562
  }
548
- return 0
563
+ }
564
+
565
+ /**
566
+ * Replace the binary at `dest`, at `current`, with the verified release
567
+ * `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
568
+ */
569
+ async function upgradeBinary(
570
+ dest: string,
571
+ spec: string,
572
+ current = VERSION,
573
+ step = '',
574
+ key: SigningKey = NPM_SIGNING_KEY,
575
+ ): Promise<void> {
576
+ const pkg = platformPackage()
577
+ let installed: string | undefined
578
+ const replaced = await replaceBinary(
579
+ dest,
580
+ () =>
581
+ releaseBinary(
582
+ pkg,
583
+ spec,
584
+ (version) =>
585
+ ui.print(
586
+ 'out',
587
+ ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
588
+ ui.text(ui.dim(dest)),
589
+ ),
590
+ key,
591
+ current,
592
+ ),
593
+ // The replaced binary's own version: the new build speaks for itself
594
+ // rather than this process guessing, and one that cannot is rolled back.
595
+ (bin) => {
596
+ installed = answeredVersion(bin)
597
+ return installed !== undefined
598
+ },
599
+ )
600
+ ui.print(
601
+ 'out',
602
+ replaced
603
+ ? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
604
+ : ui.status('info', 'vx upgrade', `${step}already at ${current}`),
605
+ )
606
+ }
607
+
608
+ /** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
609
+ function answeredVersion(bin: string): string | undefined {
610
+ return startedVersion(bin)?.split(' ')[1]
611
+ }
612
+
613
+ /**
614
+ * Bring `from`, the global vx that handed this run to the project's own
615
+ * (`VX_HANDED_OFF`), to `version`, the way it was installed: a binary
616
+ * installed by hand is replaced, a package manager's global install is
617
+ * updated by that manager. A refusal names it.
618
+ */
619
+ export async function upgradeGlobal(
620
+ from: string,
621
+ version: string,
622
+ key: SigningKey = NPM_SIGNING_KEY,
623
+ ): Promise<void> {
624
+ await named(`global vx ${from}`, async () => {
625
+ const current = answeredVersion(from)
626
+ if (current === undefined) {
627
+ throw new UserError('vx upgrade: it did not answer `--version`, so it is left as it is')
628
+ }
629
+ if (current === version) {
630
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
631
+ return
632
+ }
633
+ const install = managedInstall(from, path.dirname(from))
634
+ if (install === null) await upgradeBinary(from, version, current, 'global ', key)
635
+ else await upgradeManaged(install, version, current, 'global ')
636
+ })
637
+ }
638
+
639
+ /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
640
+ async function named(what: string, run: () => Promise<void>): Promise<void> {
641
+ try {
642
+ await run()
643
+ } catch (err) {
644
+ if (!(err instanceof UserError)) throw err
645
+ throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
646
+ }
549
647
  }
550
648
 
551
649
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
552
650
  const unknown = args.find((a) => a.startsWith('-'))
553
651
  if (unknown !== undefined) {
554
- process.stderr.write(
555
- `vx upgrade: unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}\n`,
652
+ sayNotice(
653
+ 'error',
654
+ 'vx upgrade',
655
+ `unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
556
656
  )
557
657
  return 1
558
658
  }
@@ -560,7 +660,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
560
660
  // the first one went in (`vx upgrade v0.0.22 v0.0.23`).
561
661
  const [tag, extra] = args
562
662
  if (extra !== undefined) {
563
- process.stderr.write(`vx upgrade: unexpected argument: ${extra}${seeHelp('upgrade')}\n`)
663
+ sayNotice('error', 'vx upgrade', `unexpected argument: ${extra}${seeHelp('upgrade')}`)
564
664
  return 1
565
665
  }
566
666
  if (!isCompiledBinary()) {
@@ -571,28 +671,21 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
571
671
  }
572
672
  const dest = process.execPath
573
673
  const install = managedInstall(dest, process.cwd())
574
- if (install !== null) return await upgradeManaged(install, tag)
575
- const pkg = platformPackage()
576
- const spec = versionSpec(tag)
577
- let installed = ''
578
- const replaced = await replaceBinary(
579
- dest,
580
- () =>
581
- releaseBinary(pkg, spec, (version) =>
582
- process.stdout.write(`vx upgrade: ${VERSION} → ${version} (${dest})\n`),
583
- ),
584
- // The replaced binary's own version: the new build speaks for itself
585
- // rather than this process guessing, and one that cannot is rolled back.
586
- (bin) => {
587
- installed = startedVersion(bin) ?? ''
588
- return installed !== ''
589
- },
590
- )
591
- process.stdout.write(
592
- replaced
593
- ? `vx upgrade: installed ${installed}\n`
594
- : `vx upgrade: already at ${VERSION} — nothing to do\n`,
595
- )
674
+ // A global vx handed this run to the project's own: both move, to one
675
+ // version, the project first (owner, 2026-10-15).
676
+ const from = process.env['VX_HANDED_OFF']
677
+ if (from !== undefined) {
678
+ const version = await targetVersion(tag)
679
+ await named('project vx', () =>
680
+ install === null
681
+ ? upgradeBinary(dest, version, VERSION, 'project ')
682
+ : upgradeManaged(install, version, VERSION, 'project '),
683
+ )
684
+ await upgradeGlobal(from, version)
685
+ return 0
686
+ }
687
+ if (install !== null) await upgradeManaged(install, await targetVersion(tag))
688
+ else await upgradeBinary(dest, versionSpec(tag))
596
689
  return 0
597
690
  }
598
691
 
@@ -5,7 +5,8 @@
5
5
  // as Ctrl-C would and the loop starts the next one; a cycle running any
6
6
  // other work is never stopped.
7
7
 
8
- import { createEventBus, type RunOptions } from '../orchestrator/index.js'
8
+ import { type RunOptions } from '../orchestrator/index.js'
9
+ import { createEventBus } from '../report/index.js'
9
10
 
10
11
  export interface WatchCycle {
11
12
  /** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
@@ -17,7 +17,7 @@ import { asTrees } from '../cache/index.js'
17
17
  import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
18
18
  import { isIgnoredDir } from './watch-fs.js'
19
19
 
20
- const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
20
+ const IGNORED_SUFFIXES = ['~']
21
21
 
22
22
  /**
23
23
  * True for a path the ignore filter drops. Every project dir is watched
@@ -32,8 +32,13 @@ export function isIgnoredWatchPath(rel: string): boolean {
32
32
  const segments = rel.split(path.sep)
33
33
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
34
34
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
35
+ const name = segments[segments.length - 1]!
36
+ // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
37
+ // renamed away before the judgement, it read as a deletion and ran one
38
+ // more cycle where an uncached task makes ignored paths count.
39
+ if (name.startsWith('.vx-tmp-')) return true
35
40
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
36
- return segments[segments.length - 1] === 'vx.lock.json'
41
+ return name === 'vx.lock.json'
37
42
  }
38
43
 
39
44
  /**
@@ -161,7 +161,12 @@ export class ChangeJudge {
161
161
  spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
162
162
  if (this.lastCycle === undefined && !open) return false
163
163
  try {
164
- const m = fs.statSync(abs).mtimeMs
164
+ // The later of the two clocks: a cache hit restores an output with
165
+ // the mtime it was saved with, and read by mtime alone the restore
166
+ // was the user's edit and ran one more cycle (solid, M 2026-10-15).
167
+ // The restore's write moves the ctime.
168
+ const st = fs.statSync(abs)
169
+ const m = Math.max(st.mtimeMs, st.ctimeMs)
165
170
  const start = this.lastCycle?.start ?? this.ctx.armedAt
166
171
  return m >= start && (open || m <= this.lastCycle!.end)
167
172
  } catch {