@vzn/vx 0.0.649 → 0.0.651

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 (122) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +225 -51
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/fold-memo.ts +8 -1
  8. package/src/cache/git-inputs.ts +188 -39
  9. package/src/cache/index.ts +12 -0
  10. package/src/cache/inputs.ts +3 -0
  11. package/src/cache/layer.ts +11 -0
  12. package/src/cache/layered-cache.ts +4 -0
  13. package/src/cache/linked-inputs.ts +3 -0
  14. package/src/cache/lockfile-memo.ts +120 -0
  15. package/src/cache/output-index.ts +5 -2
  16. package/src/cache/remote-hits.ts +9 -1
  17. package/src/cache/remote-lane.ts +2 -1
  18. package/src/cache/run-history.ts +11 -1
  19. package/src/cache/run-notes.ts +123 -0
  20. package/src/cache/schema.ts +54 -1
  21. package/src/cli/cache.ts +26 -16
  22. package/src/cli/completions.ts +4 -2
  23. package/src/cli/docs.ts +10 -11
  24. package/src/cli/help.ts +12 -23
  25. package/src/cli/index.ts +4 -9
  26. package/src/cli/info.ts +15 -7
  27. package/src/cli/init.ts +23 -15
  28. package/src/cli/last.ts +224 -223
  29. package/src/cli/lock.ts +55 -5
  30. package/src/cli/plan-format.ts +100 -118
  31. package/src/cli/plugin-commands.ts +10 -3
  32. package/src/cli/plugin-templates.ts +2 -2
  33. package/src/cli/run.ts +46 -27
  34. package/src/cli/select.ts +43 -5
  35. package/src/cli/show.ts +38 -42
  36. package/src/cli/task-verb.ts +22 -0
  37. package/src/cli/upgrade.ts +3 -1
  38. package/src/cli/watch-fs.ts +6 -1
  39. package/src/cli/watch-judge.ts +2 -1
  40. package/src/cli/watch.ts +83 -44
  41. package/src/cli/why.ts +126 -92
  42. package/src/cli/workspace-config.ts +2 -1
  43. package/src/exec/executor.ts +2 -1
  44. package/src/exec/kill-tree.ts +58 -5
  45. package/src/exec/local-executor.ts +1 -1
  46. package/src/exec/pipes.ts +152 -0
  47. package/src/exec/runner.ts +199 -182
  48. package/src/exec/sandbox-binds.ts +1 -1
  49. package/src/exec/sandbox-runtime.ts +11 -2
  50. package/src/exec/spawn-off.ts +154 -0
  51. package/src/exec/warm-shell.ts +146 -0
  52. package/src/graph/critical-path.ts +40 -28
  53. package/src/graph/priorities.ts +98 -44
  54. package/src/graph/scheduler.ts +12 -2
  55. package/src/graph/task-graph.ts +27 -1
  56. package/src/handoff.ts +10 -7
  57. package/src/history/history.ts +39 -2
  58. package/src/history/index.ts +3 -0
  59. package/src/history/metrics.ts +9 -1
  60. package/src/index.ts +12 -3
  61. package/src/orchestrator/doctor.ts +3 -3
  62. package/src/orchestrator/execute-task.ts +16 -2
  63. package/src/orchestrator/graph-memo.ts +50 -1
  64. package/src/orchestrator/graph-stage.ts +6 -0
  65. package/src/orchestrator/hit-restore.ts +3 -0
  66. package/src/orchestrator/index.ts +3 -1
  67. package/src/orchestrator/live-usage.ts +98 -0
  68. package/src/orchestrator/locked-digests.ts +63 -0
  69. package/src/orchestrator/lockfile-claim.ts +176 -102
  70. package/src/orchestrator/miss-save.ts +31 -3
  71. package/src/orchestrator/persistent-task.ts +3 -1
  72. package/src/orchestrator/plan.ts +5 -1
  73. package/src/orchestrator/plugin-host.ts +61 -0
  74. package/src/orchestrator/plugin.ts +49 -4
  75. package/src/orchestrator/prepare.ts +18 -2
  76. package/src/orchestrator/projects.ts +4 -1
  77. package/src/orchestrator/run-context.ts +29 -10
  78. package/src/orchestrator/run-failures.ts +35 -114
  79. package/src/orchestrator/run-records.ts +10 -0
  80. package/src/orchestrator/run.ts +89 -26
  81. package/src/orchestrator/sandbox-request.ts +2 -2
  82. package/src/orchestrator/signals.ts +8 -5
  83. package/src/orchestrator/steps.ts +2 -2
  84. package/src/orchestrator/task-hash.ts +17 -3
  85. package/src/report/forecast.ts +12 -33
  86. package/src/report/framed-output.ts +388 -161
  87. package/src/report/index.ts +8 -2
  88. package/src/report/logger.ts +100 -36
  89. package/src/report/status-line.ts +81 -365
  90. package/src/report/summary.ts +104 -131
  91. package/src/ui/ansi.ts +48 -0
  92. package/src/ui/components.ts +506 -0
  93. package/src/ui/index.ts +79 -0
  94. package/src/ui/kit.ts +37 -0
  95. package/src/ui/live-region.ts +363 -0
  96. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  97. package/src/ui/terminal.ts +51 -0
  98. package/src/ui/theme.ts +224 -0
  99. package/src/ui/version-line.ts +28 -0
  100. package/src/util/config-frame.ts +65 -10
  101. package/src/util/edit-distance.ts +6 -1
  102. package/src/util/hidden-prompt.ts +8 -2
  103. package/src/util/index.ts +2 -15
  104. package/src/util/paths.ts +20 -2
  105. package/src/util/secret-mask.ts +39 -3
  106. package/src/util/secrets.ts +3 -0
  107. package/src/util/settle.ts +31 -0
  108. package/src/util/timing.ts +4 -16
  109. package/src/workspace/affected.ts +89 -30
  110. package/src/workspace/config-schema.ts +4 -2
  111. package/src/workspace/dependencies-lock.ts +191 -0
  112. package/src/workspace/filter.ts +28 -0
  113. package/src/workspace/index.ts +9 -0
  114. package/src/workspace/lockfile.ts +159 -6
  115. package/src/workspace/migration.ts +4 -3
  116. package/src/workspace/workspace.ts +3 -0
  117. package/src/orchestrator/run-steps.ts +0 -93
  118. package/src/util/colors.ts +0 -130
  119. package/src/util/report.ts +0 -44
  120. package/src/util/ui.ts +0 -258
  121. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  122. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/select.ts CHANGED
@@ -37,12 +37,13 @@ import {
37
37
  nearest,
38
38
  relPosix,
39
39
  UserError,
40
- sayNotice,
41
- ui,
42
40
  } from '../util/index.js'
41
+ import * as ui from '../ui/index.js'
42
+ import { sayNotice } from '../ui/index.js'
43
43
  import {
44
44
  claimedAffected,
45
45
  claimedExtraFiles,
46
+ claimedDigests,
46
47
  fingerprintClaims,
47
48
  gitOfDiscovery,
48
49
  hasHook,
@@ -132,6 +133,7 @@ async function workspaceFingerprintClaims(
132
133
  files: new Set(claims.keys()),
133
134
  affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
134
135
  extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes, ctx),
136
+ digests: (file) => claimedDigests(claims.get(file)!, file, ctx),
135
137
  }
136
138
  }
137
139
 
@@ -705,6 +707,31 @@ function didYouMean(
705
707
  return best !== undefined ? `. Did you mean tag:${best}?` : `. Tags: ${listed(known)}`
706
708
  }
707
709
 
710
+ /**
711
+ * The patterns a comma list names, or one when it is none. A comma inside
712
+ * a glob's braces is no list (`./packages/{a,b}`), and a braced directory
713
+ * list (Turbo's `{cli,docs}`) is one braced directory each: split at every
714
+ * comma, it hinted `--filter {cli --filter docs}`.
715
+ */
716
+ function commaList(pattern: string): string[] {
717
+ const braced = pattern.length > 2 && pattern.startsWith('{') && pattern.endsWith('}')
718
+ const body = braced ? pattern.slice(1, -1) : pattern
719
+ const parts: string[] = []
720
+ let depth = 0
721
+ let start = 0
722
+ for (let i = 0; i <= body.length; i++) {
723
+ const c = body[i]
724
+ if (c === '{') depth++
725
+ else if (c === '}') depth--
726
+ else if (i === body.length || (c === ',' && depth === 0)) {
727
+ const part = body.slice(start, i)
728
+ if (part !== '') parts.push(braced ? `{${part}}` : part)
729
+ start = i + 1
730
+ }
731
+ }
732
+ return parts
733
+ }
734
+
708
735
  /** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
709
736
  function didYouMeanProject(
710
737
  unmatched: readonly string[],
@@ -725,15 +752,26 @@ function didYouMeanProject(
725
752
  return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
726
753
  }
727
754
  // Nx's `--projects=a,b`: the edit distance hinted the first name alone.
728
- const parts = pattern.split(',').filter((p) => p !== '')
755
+ const parts = commaList(pattern)
729
756
  if (parts.length > 1) {
730
757
  return `. A filter is one pattern: ${parts.map((p) => `--filter ${p}`).join(' ')}`
731
758
  }
759
+ // The pattern as typed with the name put right: `apq...` hinted `app`,
760
+ // and the run it was copied into dropped the dependencies.
761
+ const meant = (name: string): string => `. Did you mean ${pattern.replace(typed, name)}?`
762
+ // A task id where a project goes (`--filter app#build`): the filter
763
+ // takes the project, and the projects listed beside it hid why.
764
+ const hash = typed.indexOf('#')
765
+ const project = typed.slice(0, Math.max(hash, 0))
766
+ const owner =
767
+ hash <= 0 ? undefined : names.includes(project) ? project : nearest(project, names)
768
+ if (owner !== undefined)
769
+ return `. A filter names projects; did you mean ${pattern.replace(typed, owner)}?`
732
770
  const best = nearest(typed, names)
733
- if (best !== undefined) return `. Did you mean ${best}?`
771
+ if (best !== undefined) return meant(best)
734
772
  const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
735
773
  const scoped = bare === undefined ? undefined : byBare.get(bare)
736
- if (scoped?.length === 1) return `. Did you mean ${scoped[0]}?`
774
+ if (scoped?.length === 1) return meant(scoped[0]!)
737
775
  }
738
776
  return names.length === 0 ? '' : `. Projects: ${listed(names)}`
739
777
  }
package/src/cli/show.ts CHANGED
@@ -13,17 +13,15 @@ import { isDefaultBuild } from '../graph/index.js'
13
13
  import { affectedTaskProjects } from '../orchestrator/index.js'
14
14
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
15
15
  import {
16
- type ColorSupport,
17
- detectColors,
18
16
  listed,
19
17
  nearMatches,
20
18
  relPosix,
21
19
  secretMask,
22
20
  type SecretMask,
23
21
  UserError,
24
- sayNotice,
25
- ui,
26
22
  } from '../util/index.js'
23
+ import * as ui from '../ui/index.js'
24
+ import { sayNotice } from '../ui/index.js'
27
25
  import { affectedFilterFor, resolveFilters } from './select.js'
28
26
  import {
29
27
  discoverCliProjects,
@@ -48,6 +46,8 @@ interface ShowArgs {
48
46
  filters: string[]
49
47
  /** `--affected[=<base>]`: `''` is the default base. */
50
48
  affected?: string
49
+ /** The word after a bare `--affected`, read as the target: the base meant, when nothing is named so. */
50
+ spacedBase?: string
51
51
  error?: string
52
52
  }
53
53
 
@@ -67,8 +67,11 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
67
67
  error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
68
68
  }
69
69
  out.filters.push(v)
70
- } else if (a === '--affected') out.affected = ''
71
- else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
70
+ } else if (a === '--affected') {
71
+ out.affected = ''
72
+ const next = args[i + 1]
73
+ if (next !== undefined && !next.startsWith('-')) out.spacedBase ??= next
74
+ } else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
72
75
  else if (a.startsWith('-'))
73
76
  return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
74
77
  else if (out.target === 'project')
@@ -188,7 +191,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
188
191
  `no projects: ${EMPTY_LOCK.charAt(0).toLowerCase()}${EMPTY_LOCK.slice(1)}`,
189
192
  )
190
193
  const shown = metas.filter((m) => inSelection(m.name))
191
- process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
194
+ emit(renderList(root, shown, projects, parsed.format))
192
195
  return 0
193
196
  }
194
197
 
@@ -206,10 +209,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
206
209
  if (declares(task)) names.add(t)
207
210
  // `nx show projects` lists them; here a bare `vx show` does.
208
211
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
209
- throw new UserError(
210
- `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
211
- 'VX_E_UNKNOWN_TASK',
212
- )
212
+ const unknown = `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
213
+ const base =
214
+ parsed.spacedBase === projectName
215
+ ? `${unknown.endsWith('.') ? '' : '.'} \`--affected\` takes its base with '=': --affected=${projectName}`
216
+ : ''
217
+ throw new UserError(`${unknown}${base}`, 'VX_E_UNKNOWN_TASK')
213
218
  }
214
219
  let shown = declaring.filter((p) => inSelection(p.name))
215
220
  if (byDiff !== undefined) {
@@ -243,7 +248,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
243
248
  : `no selected project declares ${projectName} (${n} outside the selection ${n === 1 ? 'does' : 'do'})`,
244
249
  )
245
250
  }
246
- process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
251
+ emit(renderTaskAcross(root, shown, projectName!, parsed.format))
247
252
  return 0
248
253
  }
249
254
 
@@ -253,9 +258,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
253
258
  const dir = projectDir(root, meta)
254
259
 
255
260
  if (taskName === undefined) {
256
- process.stdout.write(
257
- renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
258
- )
261
+ emit(renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format))
259
262
  return 0
260
263
  }
261
264
 
@@ -266,7 +269,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
266
269
  'VX_E_UNKNOWN_TASK',
267
270
  )
268
271
  }
269
- process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format, colorsOut()))
272
+ emit(renderTask(meta.name, dir, taskName, task, parsed.format))
270
273
  return 0
271
274
  }
272
275
 
@@ -339,8 +342,13 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
339
342
  }
340
343
  }
341
344
 
342
- const colorsOut = (): ColorSupport => detectColors(process.stdout)
343
- const PLAIN: ColorSupport = { enabled: false }
345
+ /** What `vx show` prints: JSON a program reads, or blocks a person does. */
346
+ type Shown = string | readonly ui.Block[]
347
+
348
+ function emit(shown: Shown): void {
349
+ if (typeof shown === 'string') ui.data('out', shown)
350
+ else if (shown.length > 0) ui.print('out', ...shown)
351
+ }
344
352
 
345
353
  /** One block of several, with no blank line between them. */
346
354
  const joined = (blocks: readonly ui.Block[]): ui.Block => ({
@@ -352,8 +360,7 @@ function renderList(
352
360
  metas: readonly ProjectMeta[],
353
361
  projects: ReadonlyMap<string, ProjectEntry>,
354
362
  format: 'pretty' | 'json',
355
- colors: ColorSupport = PLAIN,
356
- ): string {
363
+ ): Shown {
357
364
  const rows = metas.map((meta) => {
358
365
  const tasks = projects.get(meta.name)?.config.tasks ?? {}
359
366
  return {
@@ -368,7 +375,7 @@ function renderList(
368
375
  if (format === 'json') {
369
376
  return `${JSON.stringify(rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })))}\n`
370
377
  }
371
- if (rows.length === 0) return ''
378
+ if (rows.length === 0) return []
372
379
  const anyTags = rows.some((r) => r.tags.length > 0)
373
380
  const cells = rows.map((r) => {
374
381
  const n = r.tasks.length
@@ -385,7 +392,7 @@ function renderList(
385
392
  return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
386
393
  })
387
394
  const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
388
- return ui.render([ui.table(columns, cells)], colors)
395
+ return [ui.section('Projects', String(rows.length), ui.table(columns, cells))]
389
396
  }
390
397
 
391
398
  function renderProject(
@@ -394,8 +401,7 @@ function renderProject(
394
401
  config: ProjectConfig | null,
395
402
  hasConfigFile: boolean,
396
403
  format: 'pretty' | 'json',
397
- colors: ColorSupport = PLAIN,
398
- ): string {
404
+ ): Shown {
399
405
  if (format === 'json') {
400
406
  // Round-trip so the printed object is exactly the JSON form of the
401
407
  // resolved config (drops `undefined` fields).
@@ -414,7 +420,7 @@ function renderProject(
414
420
  const blocks = tasks.map(([taskName, task]) =>
415
421
  joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
416
422
  )
417
- return ui.render([ui.report(name, head), ...blocks], colors)
423
+ return [ui.section(name, ui.fields(head), ...blocks)]
418
424
  }
419
425
 
420
426
  function renderTask(
@@ -423,13 +429,12 @@ function renderTask(
423
429
  taskName: string,
424
430
  task: TaskConfig,
425
431
  format: 'pretty' | 'json',
426
- colors: ColorSupport = PLAIN,
427
- ): string {
432
+ ): Shown {
428
433
  if (format === 'json') {
429
434
  const obj = { name, dir, task: taskName, config: shownTask(task) }
430
435
  return `${JSON.stringify(obj)}\n`
431
436
  }
432
- return ui.render([taskReport(name, dir, taskName, task)], colors)
437
+ return [taskReport(name, dir, taskName, task)]
433
438
  }
434
439
 
435
440
  function renderTaskAcross(
@@ -437,8 +442,7 @@ function renderTaskAcross(
437
442
  declaring: readonly ProjectEntry[],
438
443
  taskName: string,
439
444
  format: 'pretty' | 'json',
440
- colors: ColorSupport,
441
- ): string {
445
+ ): Shown {
442
446
  if (format === 'json') {
443
447
  const list = declaring.map((p) => ({
444
448
  name: p.name,
@@ -448,22 +452,14 @@ function renderTaskAcross(
448
452
  }))
449
453
  return `${JSON.stringify(list)}\n`
450
454
  }
451
- if (declaring.length === 0) return ''
452
- return ui.render(
453
- [
454
- joined(
455
- declaring.map((p) =>
456
- taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
457
- ),
458
- ),
459
- ],
460
- colors,
455
+ return declaring.map((p) =>
456
+ taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
461
457
  )
462
458
  }
463
459
 
464
- /** `─ app#build ───`, the project's dir, then every field the run reads. */
460
+ /** The `app#build` panel: the project's dir, then every field the run reads. */
465
461
  function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
466
- return ui.report(`${name}#${taskName}`, [['dir', dir], ...taskRows(task)])
462
+ return ui.section(`${name}#${taskName}`, ui.fields([['dir', dir], ...taskRows(task)]))
467
463
  }
468
464
 
469
465
  /** Every field the run reads, in the order the schema declares them. */
@@ -84,6 +84,28 @@ export async function taskNamesHere(cwd: string): Promise<string[] | null> {
84
84
  return [...names].sort()
85
85
  }
86
86
 
87
+ /**
88
+ * "no projects declare task(s): …" for the bare names no project in the
89
+ * workspace declares, or undefined. A diff that matched nothing judged no
90
+ * name, so `vx run tset --affected` exited 0 as "nothing affected"
91
+ * (vercel/turborepo#12268). A workspace that does not load judges none.
92
+ */
93
+ export async function undeclaredTasks(
94
+ tasks: readonly string[],
95
+ cwd: string,
96
+ ): Promise<string | undefined> {
97
+ const projects = await workspaceProjects(cwd)
98
+ if (projects === null) return undefined
99
+ const declared = new Set<string>()
100
+ for (const p of projects.values())
101
+ for (const [t, task] of Object.entries(p.config.tasks ?? {}))
102
+ if (!isDefaultBuild(task)) declared.add(t)
103
+ const missing = tasks.filter((t) => !t.includes('#') && !declared.has(t))
104
+ if (missing.length === 0) return undefined
105
+ const near = [...new Set(missing.map((t) => nearest(t, declared)).filter((t) => t !== undefined))]
106
+ return `no projects declare task(s): ${missing.join(', ')}.${near.length > 0 ? ` Did you mean ${near.join(', ')}?` : ''}`
107
+ }
108
+
87
109
  async function workspaceProjects(
88
110
  cwd: string,
89
111
  ): Promise<Awaited<ReturnType<typeof loadCliProjects>> | null> {
@@ -22,7 +22,9 @@ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/p
22
22
  import path from 'node:path'
23
23
  import { flagHint, refuse, seeHelp } from './help.js'
24
24
  import { resolveRegistry, type Registry } from './registry.js'
25
- import { UserError, sayNotice, ui } from '../util/index.js'
25
+ import { UserError } from '../util/index.js'
26
+ import * as ui from '../ui/index.js'
27
+ import { sayNotice } from '../ui/index.js'
26
28
  import { findWorkspaceRoot } from '../workspace/index.js'
27
29
  import { VERSION } from '../version.js'
28
30
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
- import { ui } from '../util/index.js'
7
+ import * as ui from '../ui/index.js'
8
8
 
9
9
  /** Directories whose changes never trigger a re-run, wherever they appear. */
10
10
  export const IGNORED_SEGMENTS = ['node_modules', '.git']
@@ -493,6 +493,11 @@ export class WatcherPool {
493
493
  return handle
494
494
  }
495
495
 
496
+ /** The watchers open now, each an OS watch or the poller in its slot. */
497
+ get open(): number {
498
+ return this.watchers.filter((w) => w !== CLOSED).length
499
+ }
500
+
496
501
  /** Settles once every arm so far has proved delivery or fallen back. */
497
502
  async proved(): Promise<void> {
498
503
  await Promise.all(this.proofs)
@@ -5,7 +5,8 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import { filesBehind } from '../cache/index.js'
8
- import { printable, ui, xxh3 } from '../util/index.js'
8
+ import { printable, xxh3 } from '../util/index.js'
9
+ import * as ui from '../ui/index.js'
9
10
  import { gitIgnored, gitSpeller } from './watch-filter.js'
10
11
  import { modifiedBefore } from './watch-fs.js'
11
12
 
package/src/cli/watch.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
- import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
17
+ import { parseRunArgs, resolveRunOptions, type RunArgs, spacedHint } from './run.js'
18
18
  import { refuse, seeHelp } from './help.js'
19
19
  import {
20
20
  fingerprintClaims,
@@ -45,14 +45,9 @@ import {
45
45
  import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
46
46
  import { ChangeJudge } from './watch-judge.js'
47
47
  import { type WatchCycle, watchCycle } from './watch-cycle.js'
48
- import {
49
- hangupIgnored,
50
- maskedLine,
51
- printable,
52
- restartTimings,
53
- sayNotice,
54
- ui,
55
- } from '../util/index.js'
48
+ import { hangupIgnored, maskedLine, printable, restartTimings } from '../util/index.js'
49
+ import * as ui from '../ui/index.js'
50
+ import { sayNotice } from '../ui/index.js'
56
51
  import { sweepConfigs, watchedProjects } from './watch-set.js'
57
52
  import { isCacheHit } from '../graph/index.js'
58
53
  import { resolveOutputView } from '../report/index.js'
@@ -72,7 +67,7 @@ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[])
72
67
  const mode = resolveOutputView(opts).mode
73
68
  if (mode !== 'broad' && mode !== 'errors-only' && mode !== 'none') return
74
69
  const n = outcomes.length
75
- process.stdout.write(` ${n === 1 ? '1 task' : `${n} tasks`} cached, nothing ran\n`)
70
+ ui.print('out', ui.line(' ', n === 1 ? '1 task' : `${n} tasks`, ' cached, nothing ran'))
76
71
  }
77
72
 
78
73
  /** Wait this long after the last filesystem event before re-running. */
@@ -129,7 +124,8 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
129
124
  const cwd = process.cwd()
130
125
  const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
131
126
  if ('error' in resolved) {
132
- sayNotice('error', 'vx watch', resolved.error)
127
+ const hint = resolved.code === 'VX_E_UNKNOWN_TASK' ? await spacedHint(parsed, cwd) : ''
128
+ sayNotice('error', 'vx watch', `${resolved.error}${hint}`)
133
129
  return 1
134
130
  }
135
131
  if ('nothingSelected' in resolved) {
@@ -166,7 +162,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
166
162
  // landed, the aborted cycle's summary printed below it.
167
163
  const stopped = (): number => {
168
164
  if (stop.signal.reason === 'SIGINT') {
169
- process.stdout.write('\n')
165
+ ui.print('out', ui.gap())
170
166
  ui.say('info', 'vx watch: stopped', 'out')
171
167
  }
172
168
  return 0
@@ -249,8 +245,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
249
245
  // not (item 1017).
250
246
  let refusedToStart = false
251
247
  initial.held = runOrchestrator(initial.cycle.opts).then(
252
- (run) => {
253
- if (run.refused !== undefined) sayNotice('error', 'vx watch', run.refused)
248
+ async (run) => {
249
+ if (run.refused !== undefined) {
250
+ const hint = run.refused.startsWith('no projects declare')
251
+ ? await spacedHint(parsed, cwd)
252
+ : ''
253
+ sayNotice('error', 'vx watch', `${run.refused}${hint}`)
254
+ }
254
255
  sayAllCached(opts, run.outcomes)
255
256
  // A run that failed having run nothing refused to start: a requested
256
257
  // name no project declares (run() says which, with a "did you mean").
@@ -558,13 +559,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
558
559
  // cycle ran on with no workspace config, its plugins gone and
559
560
  // its tasks named by path.
560
561
  if (!hasWorkspaceFile()) {
561
- process.stdout.write(`\nchanged ${label}\n`)
562
+ ui.print('out', ui.gap())
563
+ ui.say('info', `changed ${label}`, 'out')
562
564
  throw new Error(
563
565
  `no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
564
566
  )
565
567
  }
566
568
  if (reread) await rearm(false)
567
- process.stdout.write('\n')
569
+ ui.print('out', ui.gap())
568
570
  ui.say('info', `changed ${label}`, 'out')
569
571
  if (!relocked) sayUnlocked(changes.changed)
570
572
  if (stop.aborted) break
@@ -738,6 +740,33 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
738
740
  }
739
741
  }
740
742
  const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
743
+ // Bun keeps ONE FSEvents stream for every `fs.watch` of a process and
744
+ // rebuilds it, from "now", on each watch opened or closed: what lands
745
+ // while it is down is lost to every watch, the proved root stream
746
+ // included, and a close proves nothing after it. A re-arm that swapped
747
+ // `apps/`'s arms lost `apps/` made again, and the edit after it, for good
748
+ // (macOS CI, 2026-11-04). So inside the root the one stream serves the
749
+ // ancestor and import arms too, and a re-arm there opens or closes none.
750
+ /** Whether the one stream hears `dir`'s children: under the root by its real path. */
751
+ const inStream = (dir: string): boolean => {
752
+ if (!oneStream) return false
753
+ const rel = path.relative(workspaceRoot, dir)
754
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
755
+ try {
756
+ return fs.realpathSync(dir) === path.join(realRoot, rel)
757
+ } catch {
758
+ return false
759
+ }
760
+ }
761
+ /** The ancestor and import directories the one stream serves (`inStream`). */
762
+ let streamedAncestors = new Set<string>()
763
+ let streamedImports = new Set<string>()
764
+ const heardInStream = (filename: string): void => {
765
+ const abs = path.join(workspaceRoot, filename)
766
+ const dir = path.dirname(abs)
767
+ if (streamedAncestors.has(dir)) ancestorEvent(dir, path.basename(abs))
768
+ if (streamedImports.has(dir)) importEvent(dir, path.basename(abs))
769
+ }
741
770
  /** The projects the workspace-wide arm names an event by. */
742
771
  let wide: readonly ProjectMeta[] = []
743
772
  // The deepest, as a nested project's file is its own.
@@ -800,6 +829,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
800
829
  * it from the outer), so each listener decides exactly as its own arm did.
801
830
  */
802
831
  const streamEvent = (filename: string): void => {
832
+ heardInStream(filename)
803
833
  const rel = filename.split(path.sep).join('/')
804
834
  if (!rel.includes('/')) rootFileEvent(rel)
805
835
  else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
@@ -819,6 +849,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
819
849
  // the rest of the tree; the ignore filter then keeps node_modules
820
850
  // / .git / .vx and declared outputs out of what remains.
821
851
  arm(workspaceRoot, true, (filename) => {
852
+ if (oneStream) heardInStream(filename)
822
853
  if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
823
854
  const abs = path.join(workspaceRoot, filename)
824
855
  if (
@@ -900,6 +931,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
900
931
  'out',
901
932
  )
902
933
  }
934
+ const importEvent = (dir: string, filename: string): void => {
935
+ if (!(importNames.get(dir)?.has(filename) ?? false)) return
936
+ const abs = path.join(dir, filename)
937
+ const rel = path.relative(workspaceRoot, abs)
938
+ if (configImportFiles.includes(abs)) {
939
+ reread = true
940
+ trigger(`config import ${rel}`, abs)
941
+ return
942
+ }
943
+ sayStaleImport(abs)
944
+ }
903
945
  const armImports = (): void => {
904
946
  const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
905
947
  const want = new Map<string, Set<string>>()
@@ -910,29 +952,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
910
952
  want.set(path.dirname(f), names)
911
953
  }
912
954
  importNames = want
955
+ streamedImports = new Set([...want.keys()].filter(inStream))
913
956
  for (const [dir, handle] of importArms) {
914
- if (want.has(dir) && !stale(dir)) continue
957
+ if (want.has(dir) && !streamedImports.has(dir) && !stale(dir)) continue
915
958
  handle.close()
916
959
  importArms.delete(dir)
917
960
  }
918
961
  for (const dir of want.keys()) {
919
- if (importArms.has(dir)) continue
962
+ if (importArms.has(dir) || streamedImports.has(dir)) continue
920
963
  // Gone for now: its ancestor's arm (`armAncestors`) hears it return.
921
964
  if (inodeOf(dir) === undefined) continue
922
965
  try {
923
966
  importArms.set(
924
967
  dir,
925
- arm(dir, false, (filename) => {
926
- if (!(importNames.get(dir)?.has(filename) ?? false)) return
927
- const abs = path.join(dir, filename)
928
- const rel = path.relative(workspaceRoot, abs)
929
- if (configImportFiles.includes(abs)) {
930
- reread = true
931
- trigger(`config import ${rel}`, abs)
932
- return
933
- }
934
- sayStaleImport(abs)
935
- }),
968
+ arm(dir, false, (filename) => importEvent(dir, filename)),
936
969
  )
937
970
  armedAs.set(dir, inodeOf(dir) ?? '')
938
971
  } catch (err) {
@@ -1012,7 +1045,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1012
1045
  }
1013
1046
  const debugArms = resolveSetting('debug.watch', undefined).value === true
1014
1047
  const sayArmed = (count: number): void => {
1015
- if (debugArms) ui.say('info', `vx watch: armed ${plural(count, 'project')}`)
1048
+ if (debugArms)
1049
+ ui.say(
1050
+ 'info',
1051
+ `vx watch: armed ${plural(count, 'project')} (${pool.open} watch${pool.open === 1 ? '' : 'es'})`,
1052
+ )
1016
1053
  }
1017
1054
 
1018
1055
  const rearm = async (say = true): Promise<void> => {
@@ -1089,6 +1126,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1089
1126
  // never heard `packages/` removed under `packages/a` (inotify did).
1090
1127
  const ancestorArms = new Map<string, WatchHandle>()
1091
1128
  let ancestorNames = new Map<string, Set<string>>()
1129
+ const ancestorEvent = (dir: string, filename: string): void => {
1130
+ // A project's parent's `.gitignore` shapes what its keys read, as the root's does.
1131
+ if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
1132
+ const abs = path.join(dir, filename)
1133
+ // A project directory still the one its arm holds is no change:
1134
+ // its arm judges what is in it. macOS reports the directory's
1135
+ // mtime here on every write at its top level, and a cycle's own
1136
+ // write started the next (solid's `types`, 2026-10-15).
1137
+ if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
1138
+ reread = true
1139
+ trigger(path.relative(workspaceRoot, abs), abs)
1140
+ }
1092
1141
  /** Each project directory below the root as these arms first saw it (`inodeOf`). */
1093
1142
  let heldDirs = new Map<string, string>()
1094
1143
  const armAncestors = (): void => {
@@ -1119,28 +1168,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1119
1168
  dir = path.dirname(dir)
1120
1169
  }
1121
1170
  }
1171
+ streamedAncestors = new Set([...ancestorNames.keys()].filter(inStream))
1122
1172
  for (const [dir, handle] of ancestorArms) {
1123
- if (ancestorNames.has(dir) && !stale(dir)) continue
1173
+ if (ancestorNames.has(dir) && !streamedAncestors.has(dir) && !stale(dir)) continue
1124
1174
  handle.close()
1125
1175
  ancestorArms.delete(dir)
1126
1176
  }
1127
1177
  for (const dir of ancestorNames.keys()) {
1128
- if (ancestorArms.has(dir)) continue
1178
+ if (ancestorArms.has(dir) || streamedAncestors.has(dir)) continue
1129
1179
  try {
1130
1180
  ancestorArms.set(
1131
1181
  dir,
1132
- arm(dir, false, (filename) => {
1133
- // A project's parent's `.gitignore` shapes what its keys read, as the root's does.
1134
- if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
1135
- const abs = path.join(dir, filename)
1136
- // A project directory still the one its arm holds is no change:
1137
- // its arm judges what is in it. macOS reports the directory's
1138
- // mtime here on every write at its top level, and a cycle's own
1139
- // write started the next (solid's `types`, 2026-10-15).
1140
- if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
1141
- reread = true
1142
- trigger(path.relative(workspaceRoot, abs), abs)
1143
- }),
1182
+ arm(dir, false, (filename) => ancestorEvent(dir, filename)),
1144
1183
  )
1145
1184
  armedAs.set(dir, inodeOf(dir) ?? '')
1146
1185
  } catch (err) {