@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/select.ts CHANGED
@@ -14,11 +14,9 @@ import {
14
14
  buildPackageGraph,
15
15
  defaultAffectedBase,
16
16
  findWorkspaceRoot,
17
- type LoadReads,
18
17
  type FingerprintClaims,
19
- FROZEN_WITHOUT_LOCK,
20
- loadProjectConfig,
21
- loadWorkspace,
18
+ lockKey,
19
+ NO_LOCK,
22
20
  parseFilter,
23
21
  type ProjectMeta,
24
22
  readLockfile,
@@ -34,6 +32,7 @@ import {
34
32
  maskedLine,
35
33
  nearest,
36
34
  UserError,
35
+ sayNotice,
37
36
  } from '../util/index.js'
38
37
  import {
39
38
  claimedAffected,
@@ -49,15 +48,15 @@ import {
49
48
  loadCliProjects,
50
49
  loadCliWorkspace,
51
50
  } from './workspace-config.js'
51
+ import { resolveSetting } from '../settings.js'
52
52
 
53
53
  /**
54
54
  * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
55
55
  * matching a changed path, one inside another project included (item
56
56
  * 954): `--affected` selects them, since the glob is that task's input. Through the run
57
57
  * path's staged load, so a glob a `project` plugin gave a config-less
58
- * package counts; evaluated live as a default run does, or read from the
59
- * lock as a `--frozen` run does. A load that fails drops to the config files that
60
- * do load, one by one — a broken out-of-scope config does not fail a
58
+ * package counts; read from the lock as a run does. A load that fails drops to the lock's
59
+ * entries, one by one — a project the lock does not hold does not fail a
61
60
  * scoped run, and must not fail its selection either.
62
61
  */
63
62
  export async function workspaceGlobOwners(
@@ -85,26 +84,23 @@ export async function workspaceGlobOwners(
85
84
  } catch {
86
85
  // Fall through to the per-file sweep.
87
86
  }
88
- // A frozen run with no lock is refused here, before the tolerant sweep
89
- // below could answer "nothing affected" and exit 0 without ever reaching
90
- // the run's own refusal. Asked only once the staged load failed: a
91
- // frozen load that succeeded read the lock, and a second read parsed it
92
- // again (1,000 projects: a 1.1 MB lock, ~10 ms).
93
- if (load.frozen === true && (await readLockfile(root)) === null) {
94
- throw new UserError(FROZEN_WITHOUT_LOCK)
95
- }
87
+ // No lock is refused here, before the tolerant sweep below could answer
88
+ // "nothing affected" and exit 0 without ever reaching the run's own
89
+ // refusal. The sweep reads each entry from the lock: it never evaluates.
90
+ const lock = await readLockfile(root)
91
+ if (lock === null) throw new UserError(NO_LOCK)
96
92
  const owners: string[] = []
97
- await Promise.all(
98
- projects.map(async (meta) => {
99
- if (meta.configPath === null || meta.configPath === '') return
100
- try {
101
- if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
102
- } catch {
103
- // A config that will not load cannot be shown to declare a matching
104
- // glob; surfacing it here would fail a run the loader itself tolerates.
105
- }
106
- }),
107
- )
93
+ for (const meta of projects) {
94
+ if (meta.configPath === null || meta.configPath === '') continue
95
+ const dir = lockKey(root, meta.dir)
96
+ if (!Object.hasOwn(lock.projects, dir)) continue
97
+ try {
98
+ if (declaresMatch(lock.projects[dir]!.config)) owners.push(meta.name)
99
+ } catch {
100
+ // A hand-edited entry that will not read cannot be shown to declare a
101
+ // matching glob; surfacing it here would fail a run the load tolerates.
102
+ }
103
+ }
108
104
  return owners
109
105
  }
110
106
 
@@ -133,9 +129,7 @@ async function workspaceFingerprintClaims(
133
129
  }
134
130
 
135
131
  async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
136
- const reads: LoadReads = new Map()
137
- const root = await findWorkspaceRoot(cwd, reads)
138
- return await discoverCliProjects(await loadWorkspace(root, reads))
132
+ return await discoverCliProjects(await findWorkspaceRoot(cwd))
139
133
  }
140
134
 
141
135
  export async function findCwdProject(cwd: string): Promise<string | null> {
@@ -150,9 +144,8 @@ export async function findCwdProject(cwd: string): Promise<string | null> {
150
144
  export async function findCwdSelection(
151
145
  cwd: string,
152
146
  ): Promise<{ name: string; discovered: { root: string; projects: ProjectMeta[] } } | null> {
153
- const reads: LoadReads = new Map()
154
- const root = await findWorkspaceRoot(cwd, reads)
155
- const projects = await discoverCliProjects(await loadWorkspace(root, reads))
147
+ const root = await findWorkspaceRoot(cwd)
148
+ const projects = await discoverCliProjects(root)
156
149
  const abs = path.resolve(cwd)
157
150
  const within = (dirOf: (p: ProjectMeta) => string): string | null => {
158
151
  let best: string | null = null
@@ -251,14 +244,17 @@ function refusalOf(err: unknown): Refusal {
251
244
 
252
245
  /**
253
246
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
254
- * projects and their dependents. An empty base is the workspace's
255
- * `affectedBase` (or a plugin's `config` stage, from nx.json's
247
+ * projects and their dependents. An empty base is `run.affectedBase`
248
+ * (its variable, then the workspace's, which a plugin's `config` stage may set from nx.json's
256
249
  * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
257
250
  */
258
251
  export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
259
252
  const root = await findWorkspaceRoot(cwd)
260
253
  let base = affected
261
- if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
254
+ if (base === '') {
255
+ const ws = (await loadCliWorkspace(root)).workspaceConfig
256
+ base = (resolveSetting('run.affectedBase', ws).value as string | undefined) ?? ''
257
+ }
262
258
  if (base === '') {
263
259
  try {
264
260
  base = await defaultAffectedBase(root)
@@ -339,7 +335,6 @@ export async function resolveFilters(
339
335
  workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
340
336
  workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
341
337
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
342
- taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
343
338
  ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
344
339
  }
345
340
  let names: Set<string>
@@ -440,8 +435,10 @@ export async function resolveFilters(
440
435
  // Something matched, so the run proceeds; a pattern that matched nothing
441
436
  // alongside it is still worth a line — it is probably a typo.
442
437
  for (const f of unmatched)
443
- process.stderr.write(
444
- `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)}`,
445
442
  )
446
443
  // Each include is a union: what `--filter other` selected runs its tasks
447
444
  // whether or not the `--affected` diff reaches them, and an exclude
@@ -517,14 +514,18 @@ export async function pickTask(
517
514
  }
518
515
  }
519
516
  if (entries.length === 0 && only !== undefined) {
520
- process.stderr.write(
521
- `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(', ')})`,
522
521
  )
523
522
  return null
524
523
  }
525
524
  if (entries.length === 0) {
526
- process.stderr.write(
527
- '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\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`',
528
529
  )
529
530
  return null
530
531
  }
@@ -568,13 +569,15 @@ export async function pickTask(
568
569
  if (!(err instanceof Error) || err.name !== 'AbortError') throw err
569
570
  if (interrupted) return 'interrupted'
570
571
  out.write('\n')
571
- process.stderr.write(`vx run: no task picked\n`)
572
+ sayNotice('info', 'vx run', 'no task picked')
572
573
  return null
573
574
  }
574
575
  const n = Number(answer)
575
576
  if (!Number.isInteger(n) || n < 1 || n > entries.length) {
576
- process.stderr.write(
577
- `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})`,
578
581
  )
579
582
  return null
580
583
  }
@@ -604,7 +607,7 @@ function didYouMeanProject(
604
607
  projects: Iterable<{ name: string }>,
605
608
  ): string {
606
609
  const names = [...projects].map((p) => p.name)
607
- // A scoped name typed without its scope (`vx-mcp` for `@vzn/vx-mcp`) is
610
+ // A scoped name typed without its scope (`vx-otel` for `@vzn/vx-otel`) is
608
611
  // many edits from the whole name and none from the part after the `/`.
609
612
  const byBare = new Map<string, string[]>()
610
613
  for (const n of names) {
@@ -615,7 +618,7 @@ function didYouMeanProject(
615
618
  const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
616
619
  // Two edits from any two-letter name: `--filter //` hinted `ui` (X-13).
617
620
  if (typed === '//') {
618
- return ". `//` is Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config"
621
+ return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
619
622
  }
620
623
  const best = nearest(typed, names)
621
624
  if (best !== undefined) return `. Did you mean ${best}?`
package/src/cli/show.ts CHANGED
@@ -1,17 +1,27 @@
1
- // `vx show [target]` — introspect the workspace's LIVE resolved configs:
2
- // what a run would see here, now. Configs load through the same path a
3
- // run uses (`loadProjects`): the plugin `config` and `project` stages
4
- // apply, so a package a plugin gives tasks to shows them. No target: one
5
- // line per project. `<project>`: every task's resolved config. `<pkg>#
6
- // <task>`: one task. `<task>`: that task in every project declaring it.
7
- // Deliberately NOT the lock — vx-lock.json is already the frozen JSON.
1
+ // `vx show [target]` — introspect the workspace's resolved configs: what a
2
+ // run would see here, now. Configs load through the same path a run uses
3
+ // (`loadProjects`): from the lock, with the plugin `config` and `project`
4
+ // stages applied, so a package a plugin gives tasks to shows them. No lock
5
+ // is `NO_LOCK`. No target: one line per project. `<project>`: every task's
6
+ // resolved config. `<pkg>#<task>`: one task. `<task>`: that task in every
7
+ // project declaring it.
8
8
 
9
9
  import path from 'node:path'
10
10
  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,
@@ -23,11 +33,10 @@ import {
23
33
  type AffectedChanges,
24
34
  buildPackageGraph,
25
35
  findWorkspaceRoot,
26
- type LoadReads,
27
- loadWorkspace,
28
36
  type ProjectEntry,
29
37
  type ProjectMeta,
30
38
  } from '../workspace/index.js'
39
+ import { runRules } from '../settings.js'
31
40
 
32
41
  interface ShowArgs {
33
42
  target?: string
@@ -83,9 +92,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
83
92
  export async function showCmd(args: readonly string[]): Promise<number> {
84
93
  const parsed = parseShowArgs(args)
85
94
  if (parsed.error) return refuse('show', args, parsed.error, 'VX_E_USAGE')
86
- const reads: LoadReads = new Map()
87
- const root = await findWorkspaceRoot(process.cwd(), reads)
88
- const metas = await discoverCliProjects(await loadWorkspace(root, reads))
95
+ const root = await findWorkspaceRoot(process.cwd())
96
+ const metas = await discoverCliProjects(root)
89
97
  const byName = new Map(metas.map((m) => [m.name, m]))
90
98
 
91
99
  // `//#task` is Turbo's root package's task: the root project here (D-39),
@@ -149,20 +157,20 @@ export async function showCmd(args: readonly string[]): Promise<number> {
149
157
  affected = f
150
158
  raw.unshift(f)
151
159
  }
152
- const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
160
+ const r = await resolveFilters(process.cwd(), raw, {}, affected)
153
161
  if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
154
- if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
162
+ if ('empty' in r) sayNotice('info', 'vx show', r.empty)
155
163
  selected = new Set('empty' in r ? [] : r.names)
156
164
  if ('names' in r && r.affected !== undefined)
157
165
  byDiff = { changes: r.affected, outright: r.outright }
158
166
  }
159
167
  const inSelection = (name: string): boolean => selected === undefined || selected.has(name)
160
168
 
161
- const projects = await loadCliProjects(root, metas, scope, { noCreate: true, closure: true })
169
+ const projects = await loadCliProjects(root, metas, scope, { closure: true })
162
170
 
163
171
  if (parsed.target === undefined) {
164
172
  const shown = metas.filter((m) => inSelection(m.name))
165
- process.stdout.write(renderList(root, shown, projects, parsed.format))
173
+ process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
166
174
  return 0
167
175
  }
168
176
 
@@ -200,13 +208,13 @@ export async function showCmd(args: readonly string[]): Promise<number> {
200
208
  plugins: ws.plugins,
201
209
  workspaceRoot: root,
202
210
  cacheDir: ws.cacheDir,
203
- rules: ws.workspaceConfig?.rules,
211
+ rules: runRules(ws.workspaceConfig),
204
212
  warn: warnToStderr,
205
213
  }),
206
214
  )
207
215
  shown = shown.filter((p) => kept.has(p.name))
208
216
  }
209
- process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
217
+ process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
210
218
  return 0
211
219
  }
212
220
 
@@ -217,7 +225,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
217
225
 
218
226
  if (taskName === undefined) {
219
227
  process.stdout.write(
220
- renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format),
228
+ renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
221
229
  )
222
230
  return 0
223
231
  }
@@ -229,7 +237,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
229
237
  'VX_E_UNKNOWN_TASK',
230
238
  )
231
239
  }
232
- process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
240
+ process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format, colorsOut()))
233
241
  return 0
234
242
  }
235
243
 
@@ -286,11 +294,20 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
286
294
  }
287
295
  }
288
296
 
297
+ const colorsOut = (): ColorSupport => detectColors(process.stdout)
298
+ const PLAIN: ColorSupport = { enabled: false }
299
+
300
+ /** One block of several, with no blank line between them. */
301
+ const joined = (blocks: readonly ui.Block[]): ui.Block => ({
302
+ lines: (c) => blocks.flatMap((b) => b.lines(c)),
303
+ })
304
+
289
305
  function renderList(
290
306
  root: string,
291
307
  metas: readonly ProjectMeta[],
292
308
  projects: ReadonlyMap<string, ProjectEntry>,
293
309
  format: 'pretty' | 'json',
310
+ colors: ColorSupport = PLAIN,
294
311
  ): string {
295
312
  const rows = metas.map((meta) => {
296
313
  const tasks = projects.get(meta.name)?.config.tasks ?? {}
@@ -311,9 +328,8 @@ function renderList(
311
328
  )}\n`
312
329
  }
313
330
  if (rows.length === 0) return ''
314
- const nameW = Math.max(...rows.map((r) => r.name.length))
315
- const dirW = Math.max(...rows.map((r) => r.dir.length))
316
- const lines = rows.map((r) => {
331
+ const anyTags = rows.some((r) => r.tags.length > 0)
332
+ const cells = rows.map((r) => {
317
333
  const n = r.tasks.length
318
334
  const count = `${n} task${n === 1 ? '' : 's'}`
319
335
  // A package with no config file has only tasks a plugin gave it, or the
@@ -321,14 +337,14 @@ function renderList(
321
337
  const tasks = r.configured
322
338
  ? count
323
339
  : n === 0
324
- ? '(no vx config)'
340
+ ? 'no vx config'
325
341
  : r.onlyDefault
326
- ? `${count} (no vx config; default build)`
327
- : `${count} (no vx config; from plugins)`
328
- const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
329
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
342
+ ? `${count}, default build (no vx config)`
343
+ : `${count} from plugins (no vx config)`
344
+ return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
330
345
  })
331
- return `${lines.join('\n')}\n`
346
+ const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
347
+ return ui.render([ui.table(columns, cells)], colors)
332
348
  }
333
349
 
334
350
  function renderProject(
@@ -337,23 +353,27 @@ function renderProject(
337
353
  config: ProjectConfig | null,
338
354
  hasConfigFile: boolean,
339
355
  format: 'pretty' | 'json',
356
+ colors: ColorSupport = PLAIN,
340
357
  ): string {
341
358
  if (format === 'json') {
342
359
  // Round-trip so the printed object is exactly the JSON form of the
343
360
  // resolved config (drops `undefined` fields).
344
361
  const shown = shownConfig(config)
345
- return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
362
+ return `${JSON.stringify({ name, dir, config: shown })}\n`
346
363
  }
347
364
  const tags = config?.tags ?? []
348
365
  const tasks = Object.entries(config?.tasks ?? {})
349
- const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}${
366
+ const head: ui.FieldRow[] = [
367
+ ['dir', dir],
368
+ ...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
350
369
  // A config-less package a dependant reaches has the default `build` (X-9).
351
- hasConfigFile || tasks.length === 0 ? '' : '\n (no vx config)'
352
- }`
353
- if (tasks.length === 0)
354
- return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
355
- const blocks = tasks.map(([taskName, task]) => taskBlock(taskName, task))
356
- return `${head}\n\n${blocks.join('\n')}`
370
+ ...(hasConfigFile ? [] : [['config', 'none'] as const]),
371
+ ...(tasks.length === 0 && hasConfigFile ? [['tasks', 'none declared'] as const] : []),
372
+ ]
373
+ const blocks = tasks.map(([taskName, task]) =>
374
+ joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
375
+ )
376
+ return ui.render([ui.report(name, head), ...blocks], colors)
357
377
  }
358
378
 
359
379
  function renderTask(
@@ -362,12 +382,13 @@ function renderTask(
362
382
  taskName: string,
363
383
  task: TaskConfig,
364
384
  format: 'pretty' | 'json',
385
+ colors: ColorSupport = PLAIN,
365
386
  ): string {
366
387
  if (format === 'json') {
367
388
  const obj = { name, dir, task: taskName, config: shownTask(task) }
368
- return `${JSON.stringify(JSON.parse(JSON.stringify(obj)), null, 2)}\n`
389
+ return `${JSON.stringify(obj)}\n`
369
390
  }
370
- return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
391
+ return ui.render([taskReport(name, dir, taskName, task)], colors)
371
392
  }
372
393
 
373
394
  function renderTaskAcross(
@@ -375,6 +396,7 @@ function renderTaskAcross(
375
396
  declaring: readonly ProjectEntry[],
376
397
  taskName: string,
377
398
  format: 'pretty' | 'json',
399
+ colors: ColorSupport,
378
400
  ): string {
379
401
  if (format === 'json') {
380
402
  const list = declaring.map((p) => ({
@@ -383,20 +405,30 @@ function renderTaskAcross(
383
405
  task: taskName,
384
406
  config: shownTask(p.config.tasks![taskName]!),
385
407
  }))
386
- return `${JSON.stringify(JSON.parse(JSON.stringify(list)), null, 2)}\n`
408
+ return `${JSON.stringify(list)}\n`
387
409
  }
388
- return declaring
389
- .map(
390
- (p) =>
391
- `${p.name} — ${relPosix(root, p.dir) || '.'}\n\n${taskBlock(taskName, p.config.tasks![taskName]!)}`,
392
- )
393
- .join('\n')
410
+ if (declaring.length === 0) return ''
411
+ return ui.render(
412
+ [
413
+ joined(
414
+ declaring.map((p) =>
415
+ taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
416
+ ),
417
+ ),
418
+ ],
419
+ colors,
420
+ )
421
+ }
422
+
423
+ /** `─ app#build ───`, the project's dir, then every field the run reads. */
424
+ function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
425
+ return ui.report(`${name}#${taskName}`, [['dir', dir], ...taskRows(task)])
394
426
  }
395
427
 
396
428
  /** Every field the run reads, in the order the schema declares them. */
397
- function taskBlock(taskName: string, raw: TaskConfig): string {
429
+ function taskRows(raw: TaskConfig): ui.FieldRow[] {
398
430
  const task = shownTask(raw)
399
- const rows: [string, string][] = []
431
+ const rows: ui.FieldRow[] = []
400
432
  const list = (xs: readonly string[] | undefined): string | undefined =>
401
433
  xs === undefined ? undefined : xs.join(', ')
402
434
  const add = (label: string, value: string | number | boolean | undefined): void => {
@@ -406,7 +438,7 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
406
438
  const exec = task.exec
407
439
  rows.push(['command', exec?.command ?? '(group)'])
408
440
  add('dependsOn', list(task.dependsOn))
409
- add('timeout', exec?.timeout === undefined ? undefined : `${exec.timeout}ms`)
441
+ add('timeout', exec?.timeout)
410
442
  add('retries', exec?.retries)
411
443
  add('env.passThrough', list(exec?.env?.passThrough))
412
444
  add(
@@ -439,13 +471,5 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
439
471
  add('outputs.files', list(cache.outputs.files))
440
472
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
441
473
  }
442
- const labelW = Math.max(...rows.map(([label]) => label.length))
443
- // A multi-line value (a generated `vx_script` wrapper, a heredoc)
444
- // continues under its first line, not at column 0 where it reads as
445
- // the next task's header.
446
- const indent = `\n${' '.repeat(labelW + 4)}`
447
- const body = rows.map(
448
- ([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
449
- )
450
- return `${taskName}\n${body.join('\n')}\n`
474
+ return rows
451
475
  }
@@ -5,7 +5,7 @@
5
5
  // name would change meaning the day a plugin declares that verb.
6
6
 
7
7
  import { isDefaultBuild, nxProjectTarget, projectNamed } from '../orchestrator/index.js'
8
- import { findWorkspaceRoot, loadWorkspace } from '../workspace/index.js'
8
+ import { findWorkspaceRoot } from '../workspace/index.js'
9
9
  import { nearest } from '../util/index.js'
10
10
  import { findCwdProject } from './select.js'
11
11
  import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
@@ -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
  /**
@@ -79,14 +79,7 @@ async function workspaceProjects(
79
79
  ): Promise<Awaited<ReturnType<typeof loadCliProjects>> | null> {
80
80
  try {
81
81
  const root = await findWorkspaceRoot(cwd)
82
- return await loadCliProjects(
83
- root,
84
- await discoverCliProjects(await loadWorkspace(root)),
85
- 'all',
86
- {
87
- noCreate: true,
88
- },
89
- )
82
+ return await loadCliProjects(root, await discoverCliProjects(root))
90
83
  } catch {
91
84
  // No workspace, or a config that does not load: the caller's plain line stands.
92
85
  return null