@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
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. */
@@ -511,17 +507,14 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
511
507
  add('inputs.tasks', list(inputs.tasks))
512
508
  add('inputs.runtime', list(inputs.runtime))
513
509
  add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
510
+ add('inputs.stability.strategy', inputs.stability?.strategy)
511
+ add('inputs.stability.severity', inputs.stability?.severity)
514
512
  }
515
- const cache = task.cache
516
- if (cache !== undefined) {
517
- add('outputs.files', list(cache.outputs.files))
518
- add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
519
- add(
520
- 'outputs.clean',
521
- cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
522
- )
523
- add('inputStability.strategy', cache.inputStability?.strategy)
524
- add('inputStability.severity', cache.inputStability?.severity)
513
+ const outputs = task.outputs
514
+ if (outputs !== undefined) {
515
+ add('outputs.files', list(outputs.files))
516
+ add('outputs.workspaceFiles', list(outputs.workspaceFiles))
517
+ add('outputs.clean', outputs.clean === undefined ? undefined : outputs.clean ? 'yes' : 'no')
525
518
  }
526
519
  return rows
527
520
  }
@@ -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
 
@@ -318,7 +319,7 @@ export class ChangeJudge {
318
319
  'warn',
319
320
  byServer
320
321
  ? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
321
- : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
322
+ : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
322
323
  'out',
323
324
  )
324
325
  }
@@ -132,8 +132,8 @@ export async function sweepConfigs(
132
132
  }
133
133
  const foldTasks = (dir: string, config: ProjectConfig, names: Set<string> | null): void => {
134
134
  for (const [name, task] of Object.entries(config.tasks ?? {})) {
135
- add(dir, task.cache?.outputs?.files)
136
- add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
135
+ add(dir, task.outputs?.files)
136
+ add(workspaceRoot, task.outputs?.workspaceFiles)
137
137
  if (names !== null && !names.has(name)) continue
138
138
  if (
139
139
  task.inputs === undefined &&
@@ -147,8 +147,8 @@ export async function sweepConfigs(
147
147
  for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
148
148
  }
149
149
  for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
150
- addInputs(dir, task.inputs?.files, task.cache?.outputs?.files)
151
- addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.cache?.outputs?.workspaceFiles)
150
+ addInputs(dir, task.inputs?.files, task.outputs?.files)
151
+ addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.outputs?.workspaceFiles)
152
152
  }
153
153
  }
154
154
  const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
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").
@@ -538,7 +539,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
538
539
  const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
539
540
  try {
540
541
  let label: string | undefined = first
541
- let lockAtRun = ''
542
+ /** The lock this cycle's run read; none for the initial run. */
543
+ let lockAtRun: string | undefined
542
544
  // The initial run is collected even after a stop: its server is the
543
545
  // loop's to stop.
544
546
  while (initial !== undefined || (label !== undefined && !stop.aborted)) {
@@ -558,13 +560,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
558
560
  // cycle ran on with no workspace config, its plugins gone and
559
561
  // its tasks named by path.
560
562
  if (!hasWorkspaceFile()) {
561
- process.stdout.write(`\nchanged ${label}\n`)
563
+ ui.print('out', ui.gap())
564
+ ui.say('info', `changed ${label}`, 'out')
562
565
  throw new Error(
563
566
  `no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
564
567
  )
565
568
  }
566
569
  if (reread) await rearm(false)
567
- process.stdout.write('\n')
570
+ ui.print('out', ui.gap())
568
571
  ui.say('info', `changed ${label}`, 'out')
569
572
  if (!relocked) sayUnlocked(changes.changed)
570
573
  if (stop.aborted) break
@@ -610,13 +613,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
610
613
  // A config's own throw or a plugin's failure may quote a secret (L-11).
611
614
  const message = maskedLine(err instanceof Error ? err.message : String(err))
612
615
  sayNotice('error', 'vx watch', `cycle failed: ${message}`)
613
- } finally {
614
- // A re-lock since this cycle began: the re-arm below can
615
- // close the watcher its event was still on its way to (FSEvents'
616
- // latency, a poller's tick), and the new lock ran nothing until
617
- // the next edit (macOS CI, 2026-10-23). A cycle that read the
618
- // lock mid-write failed on it, and is the same case.
619
- if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
620
616
  }
621
617
  // After a failed cycle too: a package added with a config that does
622
618
  // not load yet fails its cycle, and unarmed, the fix to that config
@@ -625,6 +621,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
625
621
  reread = false
626
622
  await rearm()
627
623
  }
624
+ // A re-lock since this cycle began: the re-arm can close the watcher
625
+ // its event was still on its way to (FSEvents' latency, a poller's
626
+ // tick), and the new lock ran nothing until the next edit (macOS CI,
627
+ // 2026-10-23). A cycle that read the lock mid-write failed on it, and
628
+ // is the same case. Asked after the re-arm, not when the run ends: a
629
+ // lock written in between was the re-arm's to read, its new pollers
630
+ // took it as their baseline, and it was never an event (macOS CI,
631
+ // 2026-11-06).
632
+ if (lockAtRun !== undefined && lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
628
633
  // What landed mid-run is judged on settled state, one window
629
634
  // after the run, under the label of what actually arrived.
630
635
  current = undefined
@@ -738,6 +743,33 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
738
743
  }
739
744
  }
740
745
  const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
746
+ // Bun keeps ONE FSEvents stream for every `fs.watch` of a process and
747
+ // rebuilds it, from "now", on each watch opened or closed: what lands
748
+ // while it is down is lost to every watch, the proved root stream
749
+ // included, and a close proves nothing after it. A re-arm that swapped
750
+ // `apps/`'s arms lost `apps/` made again, and the edit after it, for good
751
+ // (macOS CI, 2026-11-04). So inside the root the one stream serves the
752
+ // ancestor and import arms too, and a re-arm there opens or closes none.
753
+ /** Whether the one stream hears `dir`'s children: under the root by its real path. */
754
+ const inStream = (dir: string): boolean => {
755
+ if (!oneStream) return false
756
+ const rel = path.relative(workspaceRoot, dir)
757
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return false
758
+ try {
759
+ return fs.realpathSync(dir) === path.join(realRoot, rel)
760
+ } catch {
761
+ return false
762
+ }
763
+ }
764
+ /** The ancestor and import directories the one stream serves (`inStream`). */
765
+ let streamedAncestors = new Set<string>()
766
+ let streamedImports = new Set<string>()
767
+ const heardInStream = (filename: string): void => {
768
+ const abs = path.join(workspaceRoot, filename)
769
+ const dir = path.dirname(abs)
770
+ if (streamedAncestors.has(dir)) ancestorEvent(dir, path.basename(abs))
771
+ if (streamedImports.has(dir)) importEvent(dir, path.basename(abs))
772
+ }
741
773
  /** The projects the workspace-wide arm names an event by. */
742
774
  let wide: readonly ProjectMeta[] = []
743
775
  // The deepest, as a nested project's file is its own.
@@ -800,6 +832,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
800
832
  * it from the outer), so each listener decides exactly as its own arm did.
801
833
  */
802
834
  const streamEvent = (filename: string): void => {
835
+ heardInStream(filename)
803
836
  const rel = filename.split(path.sep).join('/')
804
837
  if (!rel.includes('/')) rootFileEvent(rel)
805
838
  else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
@@ -819,6 +852,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
819
852
  // the rest of the tree; the ignore filter then keeps node_modules
820
853
  // / .git / .vx and declared outputs out of what remains.
821
854
  arm(workspaceRoot, true, (filename) => {
855
+ if (oneStream) heardInStream(filename)
822
856
  if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
823
857
  const abs = path.join(workspaceRoot, filename)
824
858
  if (
@@ -900,6 +934,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
900
934
  'out',
901
935
  )
902
936
  }
937
+ const importEvent = (dir: string, filename: string): void => {
938
+ if (!(importNames.get(dir)?.has(filename) ?? false)) return
939
+ const abs = path.join(dir, filename)
940
+ const rel = path.relative(workspaceRoot, abs)
941
+ if (configImportFiles.includes(abs)) {
942
+ reread = true
943
+ trigger(`config import ${rel}`, abs)
944
+ return
945
+ }
946
+ sayStaleImport(abs)
947
+ }
903
948
  const armImports = (): void => {
904
949
  const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
905
950
  const want = new Map<string, Set<string>>()
@@ -910,29 +955,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
910
955
  want.set(path.dirname(f), names)
911
956
  }
912
957
  importNames = want
958
+ streamedImports = new Set([...want.keys()].filter(inStream))
913
959
  for (const [dir, handle] of importArms) {
914
- if (want.has(dir) && !stale(dir)) continue
960
+ if (want.has(dir) && !streamedImports.has(dir) && !stale(dir)) continue
915
961
  handle.close()
916
962
  importArms.delete(dir)
917
963
  }
918
964
  for (const dir of want.keys()) {
919
- if (importArms.has(dir)) continue
965
+ if (importArms.has(dir) || streamedImports.has(dir)) continue
920
966
  // Gone for now: its ancestor's arm (`armAncestors`) hears it return.
921
967
  if (inodeOf(dir) === undefined) continue
922
968
  try {
923
969
  importArms.set(
924
970
  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
- }),
971
+ arm(dir, false, (filename) => importEvent(dir, filename)),
936
972
  )
937
973
  armedAs.set(dir, inodeOf(dir) ?? '')
938
974
  } catch (err) {
@@ -1012,7 +1048,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1012
1048
  }
1013
1049
  const debugArms = resolveSetting('debug.watch', undefined).value === true
1014
1050
  const sayArmed = (count: number): void => {
1015
- if (debugArms) ui.say('info', `vx watch: armed ${plural(count, 'project')}`)
1051
+ if (debugArms)
1052
+ ui.say(
1053
+ 'info',
1054
+ `vx watch: armed ${plural(count, 'project')} (${pool.open} watch${pool.open === 1 ? '' : 'es'})`,
1055
+ )
1016
1056
  }
1017
1057
 
1018
1058
  const rearm = async (say = true): Promise<void> => {
@@ -1089,6 +1129,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1089
1129
  // never heard `packages/` removed under `packages/a` (inotify did).
1090
1130
  const ancestorArms = new Map<string, WatchHandle>()
1091
1131
  let ancestorNames = new Map<string, Set<string>>()
1132
+ const ancestorEvent = (dir: string, filename: string): void => {
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
+ }
1092
1144
  /** Each project directory below the root as these arms first saw it (`inodeOf`). */
1093
1145
  let heldDirs = new Map<string, string>()
1094
1146
  const armAncestors = (): void => {
@@ -1119,28 +1171,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1119
1171
  dir = path.dirname(dir)
1120
1172
  }
1121
1173
  }
1174
+ streamedAncestors = new Set([...ancestorNames.keys()].filter(inStream))
1122
1175
  for (const [dir, handle] of ancestorArms) {
1123
- if (ancestorNames.has(dir) && !stale(dir)) continue
1176
+ if (ancestorNames.has(dir) && !streamedAncestors.has(dir) && !stale(dir)) continue
1124
1177
  handle.close()
1125
1178
  ancestorArms.delete(dir)
1126
1179
  }
1127
1180
  for (const dir of ancestorNames.keys()) {
1128
- if (ancestorArms.has(dir)) continue
1181
+ if (ancestorArms.has(dir) || streamedAncestors.has(dir)) continue
1129
1182
  try {
1130
1183
  ancestorArms.set(
1131
1184
  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
- }),
1185
+ arm(dir, false, (filename) => ancestorEvent(dir, filename)),
1144
1186
  )
1145
1187
  armedAs.set(dir, inodeOf(dir) ?? '')
1146
1188
  } catch (err) {