@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,332 @@
1
+ // What a run is asked to run: the projects a `--filter` selects, the
2
+ // ones `--affected` touches (with the orphan-path owners), the project the
3
+ // cwd sits in, and the interactive picker. Every read of the workspace
4
+ // here goes through the staged load, so the answer is the run's.
5
+
6
+ import readline from 'node:readline/promises'
7
+ import path from 'node:path'
8
+ import {
9
+ affectedProjects,
10
+ applyFilters,
11
+ buildPackageGraph,
12
+ findWorkspaceRoot,
13
+ type FingerprintClaims,
14
+ FROZEN_WITHOUT_LOCK,
15
+ listProjects,
16
+ loadProjectConfig,
17
+ loadWorkspace,
18
+ parseFilter,
19
+ type ProjectMeta,
20
+ readLockfile,
21
+ workspaceGlobsMatch,
22
+ } from '../workspace/index.js'
23
+ import type { ProjectConfig } from '../config.js'
24
+ import type { ProjectEntry } from '../workspace/index.js'
25
+ import { parseDependencySpec } from '../graph/index.js'
26
+ import { nearest, UserError } from '../util/index.js'
27
+ import { claimedAffected, fingerprintClaims } from '../orchestrator/index.js'
28
+ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
29
+
30
+ /**
31
+ * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
32
+ * matching an ORPHAN changed path (one no project owns): `--affected`
33
+ * selects them, since the glob is that task's input. Through the run
34
+ * path's staged load, so a glob a `project` plugin gave a config-less
35
+ * package counts; evaluated live as a default run does, or read from the
36
+ * lock as a `--frozen` run does. A load that fails drops to the config files that
37
+ * do load, one by one — a broken out-of-scope config does not fail a
38
+ * scoped run, and must not fail its selection either.
39
+ */
40
+ export async function workspaceGlobOwners(
41
+ root: string,
42
+ projects: readonly ProjectMeta[],
43
+ orphans: readonly string[],
44
+ load: CliLoadOptions = {},
45
+ stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
46
+ loadCliProjects(root, projects, 'all', load),
47
+ ): Promise<string[]> {
48
+ const declaresMatch = (config: ProjectConfig): boolean => {
49
+ for (const task of Object.values(config.tasks ?? {})) {
50
+ const globs = task.cache?.inputs?.workspaceFiles
51
+ if (globs === undefined) continue
52
+ if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
53
+ }
54
+ return false
55
+ }
56
+ // A frozen run with no lock is refused here, before the tolerant sweep
57
+ // below could answer "nothing affected" and exit 0 without ever reaching
58
+ // the run's own refusal.
59
+ if (load.frozen === true && (await readLockfile(root)) === null) {
60
+ throw new UserError(FROZEN_WITHOUT_LOCK)
61
+ }
62
+ try {
63
+ const staged = await stagedLoad()
64
+ return [...staged.values()].filter((p) => declaresMatch(p.config)).map((p) => p.name)
65
+ } catch {
66
+ // Fall through to the per-file sweep.
67
+ }
68
+ const owners: string[] = []
69
+ await Promise.all(
70
+ projects.map(async (meta) => {
71
+ if (meta.configPath === null || meta.configPath === '') return
72
+ try {
73
+ if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
74
+ } catch {
75
+ // A config that will not load cannot be shown to declare a matching
76
+ // glob; surfacing it here would fail a run the loader itself tolerates.
77
+ }
78
+ }),
79
+ )
80
+ return owners
81
+ }
82
+
83
+ /**
84
+ * The fingerprint files the workspace's plugins claim, with each claimant
85
+ * asked through the same host the run uses — so a bad answer is refused by
86
+ * the plugin's name here exactly as it would be at key time.
87
+ */
88
+ async function workspaceFingerprintClaims(
89
+ root: string,
90
+ projects: readonly ProjectMeta[],
91
+ load: CliLoadOptions,
92
+ ): Promise<FingerprintClaims> {
93
+ const ws = await loadCliWorkspace(root)
94
+ const claims = fingerprintClaims(ws.plugins)
95
+ const ctx = {
96
+ workspaceRoot: root,
97
+ cacheDir: load.cacheDir ?? ws.cacheDir,
98
+ warn: (m: string) => process.stderr.write(`${m}\n`),
99
+ projects: projects.map((p) => ({ name: p.name, dir: p.dir })),
100
+ }
101
+ return {
102
+ files: new Set(claims.keys()),
103
+ affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
104
+ }
105
+ }
106
+
107
+ export async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
108
+ const root = await findWorkspaceRoot(cwd)
109
+ const ws = await loadWorkspace(root)
110
+ return await listProjects(ws)
111
+ }
112
+
113
+ export async function findCwdProject(cwd: string): Promise<string | null> {
114
+ const projects = await loadWorkspaceProjects(cwd)
115
+ const abs = path.resolve(cwd)
116
+ let best: ProjectMeta | null = null
117
+ for (const p of projects) {
118
+ if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
119
+ if (!best || p.dir.length > best.dir.length) best = p
120
+ }
121
+ }
122
+ return best?.name ?? null
123
+ }
124
+
125
+ export type FilterResolution =
126
+ | {
127
+ names: string[]
128
+ /** The staged load the graph walk needed, for the run to reuse (`RunOptions.staged`). */
129
+ staged?: ReadonlyMap<string, ProjectEntry>
130
+ }
131
+ | { error: string }
132
+ | { empty: string }
133
+
134
+ /**
135
+ * The cross-project `dependsOn` edges the configs declare, project → the
136
+ * projects it names (`dependsOn: ['app#build']` makes `app` a dependency
137
+ * of the declaring project). Read from the staged load — the same configs
138
+ * the run will use — only when a filter walks the graph (`...`, `^...`).
139
+ * A spec the loader will reject is skipped here; the run reports it.
140
+ */
141
+ export async function taskEdges(
142
+ root: string,
143
+ projects: readonly ProjectMeta[],
144
+ load: CliLoadOptions,
145
+ ): Promise<{ edges: Map<string, string[]>; staged: Map<string, ProjectEntry> }> {
146
+ const staged = await loadCliProjects(root, projects, 'all', load)
147
+ return { edges: taskEdgesFrom(staged), staged }
148
+ }
149
+
150
+ /** The same edges, read from a load the caller already has. */
151
+ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<string, string[]> {
152
+ const out = new Map<string, string[]>()
153
+ for (const p of staged.values()) {
154
+ const targets = new Set<string>()
155
+ for (const task of Object.values(p.config.tasks ?? {})) {
156
+ for (const raw of task.dependsOn ?? []) {
157
+ let spec
158
+ try {
159
+ spec = parseDependencySpec(raw)
160
+ } catch {
161
+ continue
162
+ }
163
+ if (spec.kind === 'cross' && !spec.negated && spec.project !== p.name) {
164
+ targets.add(spec.project)
165
+ }
166
+ }
167
+ }
168
+ if (targets.size > 0) out.set(p.name, [...targets].sort())
169
+ }
170
+ return out
171
+ }
172
+
173
+ export async function resolveFilters(
174
+ cwd: string,
175
+ raw: string[],
176
+ load: CliLoadOptions = {},
177
+ ): Promise<FilterResolution> {
178
+ const root = await findWorkspaceRoot(cwd)
179
+ const projects = await loadWorkspaceProjects(cwd)
180
+ const parsed = raw.map((r) => parseFilter(r, root))
181
+ const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
182
+ // Every reader of the staged configs in this pass — the `pkg#task`
183
+ // edge walk, the `workspaceFiles` owners of an orphan path — shares
184
+ // ONE load, and the run reuses it (`RunOptions.staged`): the `project`
185
+ // stage runs once per project per run.
186
+ let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
187
+ const stagedOnce = (): Promise<Map<string, ProjectEntry>> =>
188
+ (stagedPromise ??= loadCliProjects(root, projects, 'all', load))
189
+ let edges: Map<string, string[]> | undefined
190
+ if (walksGraph) {
191
+ try {
192
+ edges = taskEdgesFrom(await stagedOnce())
193
+ } catch (err) {
194
+ return { error: err instanceof Error ? err.message : String(err) }
195
+ }
196
+ }
197
+ const graph = buildPackageGraph(projects, edges)
198
+
199
+ // Resolve every `[<since>]` filter against git before the pure
200
+ // applyFilters pass runs. One spawn per distinct ref — usually
201
+ // there's only one anyway.
202
+ const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
203
+ for (const f of parsed) {
204
+ if (f.gitSince === undefined) continue
205
+ try {
206
+ const names = await affectedProjects({
207
+ workspaceRoot: root,
208
+ since: f.gitSince,
209
+ projects,
210
+ workspaceGlobOwners: (orphans) =>
211
+ workspaceGlobOwners(root, projects, orphans, load, stagedOnce),
212
+ fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
213
+ })
214
+ affectedByFilter.set(f, names)
215
+ } catch (err) {
216
+ const msg = err instanceof Error ? err.message : String(err)
217
+ return { error: msg }
218
+ }
219
+ }
220
+
221
+ const unmatched: string[] = []
222
+ const selected = applyFilters({
223
+ filters: parsed,
224
+ projects,
225
+ graph,
226
+ affectedByFilter,
227
+ // A `[<since>]` selector matching nothing is the ordinary "nothing
228
+ // changed" outcome, reported below; only a name/path pattern that
229
+ // matched nothing is worth flagging as a probable typo.
230
+ onNoMatch: (f) => {
231
+ if (f.gitSince === undefined) unmatched.push(f.raw)
232
+ },
233
+ })
234
+ if (selected.size === 0) {
235
+ // "Nothing changed" is a legitimate outcome, not a typo — a docs-only
236
+ // commit must not red `vx run … --affected=origin/main`. Only report an
237
+ // error when the user named something concrete that failed to resolve.
238
+ const includes = parsed.filter((f) => !f.negate)
239
+ if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
240
+ const refs = includes.map((f) => f.gitSince).join(', ')
241
+ return { empty: `nothing affected since ${refs}` }
242
+ }
243
+ // One line, not a warning per pattern and then an error saying the same:
244
+ // the patterns are in the error, and the nearest project name is the
245
+ // hint a typo needs.
246
+ return {
247
+ error: `no projects matched filter(s): ${raw.join(', ')}${didYouMeanProject(unmatched, projects)}`,
248
+ }
249
+ }
250
+ // Something matched, so the run proceeds; a pattern that matched nothing
251
+ // alongside it is still worth a line — it is probably a typo.
252
+ for (const f of unmatched) process.stderr.write(`vx: filter "${f}" matched no projects\n`)
253
+ let staged: Map<string, ProjectEntry> | undefined
254
+ if (stagedPromise !== undefined) {
255
+ try {
256
+ staged = await stagedPromise
257
+ } catch {
258
+ // The owners walk fell through to its per-file sweep; the run loads for itself.
259
+ staged = undefined
260
+ }
261
+ }
262
+ return { names: [...selected].sort(), ...(staged !== undefined ? { staged } : {}) }
263
+ }
264
+
265
+ export interface PickedTask {
266
+ project: string
267
+ task: string
268
+ description?: string
269
+ }
270
+
271
+ export async function pickTask(
272
+ cwd: string,
273
+ io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
274
+ load: CliLoadOptions = {},
275
+ ): Promise<PickedTask | null> {
276
+ const projects = await loadWorkspaceProjects(cwd)
277
+ // The staged load: a task a `project` plugin gave a config-less package
278
+ // is on the menu, as it is in a run.
279
+ const staged = await loadCliProjects(await findWorkspaceRoot(cwd), projects, 'all', load)
280
+ const entries: PickedTask[] = []
281
+ for (const meta of projects) {
282
+ const config = staged.get(meta.name)?.config
283
+ if (config === undefined) continue
284
+ const taskNames = Object.keys(config.tasks ?? {}).sort()
285
+ for (const t of taskNames) {
286
+ const desc = config.tasks?.[t]?.description
287
+ entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
288
+ }
289
+ }
290
+ if (entries.length === 0) {
291
+ process.stderr.write(`vx run: no tasks declared in any project\n`)
292
+ return null
293
+ }
294
+ const out = io.output ?? process.stdout
295
+ const numW = String(entries.length).length
296
+ const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
297
+ out.write('Tasks:\n')
298
+ entries.forEach((e, i) => {
299
+ const n = String(i + 1).padStart(numW, ' ')
300
+ const id = `${e.project}#${e.task}`.padEnd(idW)
301
+ const desc = e.description ? ` ${e.description}` : ''
302
+ out.write(` ${n}. ${id}${desc}\n`)
303
+ })
304
+ const rl = readline.createInterface({
305
+ input: io.input ?? process.stdin,
306
+ output: io.output ?? process.stdout,
307
+ })
308
+ try {
309
+ const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
310
+ const n = Number(answer)
311
+ if (!Number.isInteger(n) || n < 1 || n > entries.length) {
312
+ process.stderr.write(`vx run: invalid selection: ${answer}\n`)
313
+ return null
314
+ }
315
+ return entries[n - 1] ?? null
316
+ } finally {
317
+ rl.close()
318
+ }
319
+ }
320
+
321
+ /** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
322
+ function didYouMeanProject(
323
+ unmatched: readonly string[],
324
+ projects: Iterable<{ name: string }>,
325
+ ): string {
326
+ const names = [...projects].map((p) => p.name)
327
+ for (const pattern of unmatched) {
328
+ const best = nearest(pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, ''), names)
329
+ if (best !== undefined) return `. Did you mean ${best}?`
330
+ }
331
+ return ''
332
+ }
package/src/cli/show.ts CHANGED
@@ -1,17 +1,20 @@
1
- // `vx show [target]` — introspect the workspace's LIVE resolved configs.
2
- // No target: one line per project. `<project>`: every task's resolved
3
- // config, evaluated with the same loader the run path uses (scoped to
4
- // that single project). `<pkg>#<task>`: one task. This is deliberately
5
- // NOT the lock — vx-lock.json is already the frozen JSON; `show` answers
6
- // "what would a live run see here, now".
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.
7
8
 
8
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
9
- import { relPosix, UserError } from '../util/index.js'
10
+ import { seeHelp } from './help.js'
11
+ import { nearMatches, relPosix, UserError } from '../util/index.js'
12
+ import { loadCliProjects } from './workspace-config.js'
10
13
  import {
11
14
  findWorkspaceRoot,
12
15
  listProjects,
13
- loadProjectConfig,
14
16
  loadWorkspace,
17
+ type ProjectEntry,
15
18
  type ProjectMeta,
16
19
  } from '../workspace/index.js'
17
20
 
@@ -28,7 +31,7 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
28
31
  let format: string | undefined
29
32
  if (a === '--format') format = args[++i] ?? ''
30
33
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
31
- else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
34
+ else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('show')}` }
32
35
  else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
33
36
  else out.target = a
34
37
 
@@ -50,55 +53,81 @@ export async function showCmd(args: readonly string[]): Promise<number> {
50
53
  }
51
54
  const root = await findWorkspaceRoot(process.cwd())
52
55
  const metas = await listProjects(await loadWorkspace(root))
56
+ const byName = new Map(metas.map((m) => [m.name, m]))
57
+
58
+ const hashAt = parsed.target?.indexOf('#') ?? -1
59
+ const projectName =
60
+ parsed.target === undefined
61
+ ? undefined
62
+ : hashAt === -1
63
+ ? parsed.target
64
+ : parsed.target.slice(0, hashAt)
65
+ const taskName = hashAt === -1 ? undefined : parsed.target!.slice(hashAt + 1)
66
+ if (taskName === '') throw new UserError(`missing task name after '#' in "${parsed.target}"`)
67
+ if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
68
+ if (!byName.has(projectName)) {
69
+ throw new UserError(
70
+ `unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
71
+ )
72
+ }
73
+ }
74
+ // A bare name that is no project is a TASK: shown in every project that
75
+ // declares it, so the whole workspace loads.
76
+ const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
77
+ const scope = projectName === undefined || bareTask ? 'all' : [projectName]
78
+
79
+ const projects = await loadCliProjects(root, metas, scope)
53
80
 
54
81
  if (parsed.target === undefined) {
55
- process.stdout.write(await renderList(root, metas, parsed.format))
82
+ process.stdout.write(renderList(root, metas, projects, parsed.format))
56
83
  return 0
57
84
  }
58
85
 
59
- const hashAt = parsed.target.indexOf('#')
60
- const projectName = hashAt === -1 ? parsed.target : parsed.target.slice(0, hashAt)
61
- const taskName = hashAt === -1 ? undefined : parsed.target.slice(hashAt + 1)
62
- if (taskName === '') throw new UserError(`missing task name after '#' in "${parsed.target}"`)
63
-
64
- const meta = metas.find((m) => m.name === projectName)
65
- if (!meta) {
66
- throw new UserError(
67
- `unknown project: "${projectName}"${suggest(
68
- projectName,
69
- metas.map((m) => m.name),
70
- )}`,
71
- )
86
+ if (bareTask) {
87
+ const declaring = [...projects.values()].filter((p) => p.config.tasks?.[projectName!])
88
+ if (declaring.length === 0) {
89
+ const names = new Set<string>()
90
+ for (const p of projects.values())
91
+ for (const t of Object.keys(p.config.tasks ?? {})) names.add(t)
92
+ throw new UserError(
93
+ `unknown project or task: "${projectName}"${suggest(projectName!, [...byName.keys(), ...names])}`,
94
+ )
95
+ }
96
+ process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
97
+ return 0
72
98
  }
73
- const config = meta.configPath === null ? null : await loadProjectConfig(meta.configPath)
99
+
100
+ const meta = byName.get(projectName!)!
101
+ const entry = projects.get(meta.name)
102
+ const config = entry?.config ?? null
74
103
  const dir = projectDir(root, meta)
75
104
 
76
105
  if (taskName === undefined) {
77
- process.stdout.write(renderProject(meta.name, dir, config, parsed.format))
106
+ process.stdout.write(
107
+ renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format),
108
+ )
78
109
  return 0
79
110
  }
80
111
 
81
112
  const task = config?.tasks?.[taskName]
82
113
  if (task === undefined) {
83
114
  throw new UserError(
84
- `unknown task: "${meta.name}#${taskName}"${suggest(
85
- taskName,
86
- Object.keys(config?.tasks ?? {}),
87
- )}`,
115
+ `unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,
88
116
  )
89
117
  }
90
118
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
91
119
  return 0
92
120
  }
93
121
 
94
- /** Simple includes-match in both directions, case-insensitive. */
122
+ /** Near misses by edit distance, plus partial names in either direction. */
95
123
  function suggest(query: string, candidates: readonly string[]): string {
96
124
  const q = query.toLowerCase()
97
- const near = candidates.filter((c) => {
125
+ const near = new Set(nearMatches(query, candidates))
126
+ for (const c of candidates) {
98
127
  const n = c.toLowerCase()
99
- return n.includes(q) || q.includes(n)
100
- })
101
- return near.length > 0 ? ` — did you mean ${near.join(', ')}?` : ''
128
+ if (n.includes(q) || q.includes(n)) near.add(c)
129
+ }
130
+ return near.size > 0 ? ` — did you mean ${[...near].join(', ')}?` : ''
102
131
  }
103
132
 
104
133
  function projectDir(root: string, meta: ProjectMeta): string {
@@ -106,22 +135,18 @@ function projectDir(root: string, meta: ProjectMeta): string {
106
135
  return rel === '' ? '.' : rel
107
136
  }
108
137
 
109
- async function renderList(
138
+ function renderList(
110
139
  root: string,
111
140
  metas: readonly ProjectMeta[],
141
+ projects: ReadonlyMap<string, ProjectEntry>,
112
142
  format: 'pretty' | 'json',
113
- ): Promise<string> {
114
- const rows = await Promise.all(
115
- metas.map(async (meta) => {
116
- const config = meta.configPath === null ? null : await loadProjectConfig(meta.configPath)
117
- return {
118
- name: meta.name,
119
- dir: projectDir(root, meta),
120
- tasks: Object.keys(config?.tasks ?? {}),
121
- configured: meta.configPath !== null,
122
- }
123
- }),
124
- )
143
+ ): string {
144
+ const rows = metas.map((meta) => ({
145
+ name: meta.name,
146
+ dir: projectDir(root, meta),
147
+ tasks: Object.keys(projects.get(meta.name)?.config.tasks ?? {}),
148
+ configured: meta.configPath !== null,
149
+ }))
125
150
  if (format === 'json') {
126
151
  return `${JSON.stringify(
127
152
  rows.map(({ name, dir, tasks }) => ({ name, dir, tasks })),
@@ -132,9 +157,14 @@ async function renderList(
132
157
  const nameW = Math.max(...rows.map((r) => r.name.length))
133
158
  const dirW = Math.max(...rows.map((r) => r.dir.length))
134
159
  const lines = rows.map((r) => {
160
+ const n = r.tasks.length
161
+ const count = `${n} task${n === 1 ? '' : 's'}`
162
+ // A package with no config file only has tasks a plugin gave it.
135
163
  const tasks = r.configured
136
- ? `${r.tasks.length} task${r.tasks.length === 1 ? '' : 's'}`
137
- : '(no vx config)'
164
+ ? count
165
+ : n > 0
166
+ ? `${count} (no vx config; from plugins)`
167
+ : '(no vx config)'
138
168
  return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}`
139
169
  })
140
170
  return `${lines.join('\n')}\n`
@@ -144,6 +174,7 @@ function renderProject(
144
174
  name: string,
145
175
  dir: string,
146
176
  config: ProjectConfig | null,
177
+ hasConfigFile: boolean,
147
178
  format: 'pretty' | 'json',
148
179
  ): string {
149
180
  if (format === 'json') {
@@ -152,9 +183,9 @@ function renderProject(
152
183
  return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config })), null, 2)}\n`
153
184
  }
154
185
  const head = `${name} — ${dir}`
155
- if (config === null) return `${head}\n (no vx config)\n`
156
- const tasks = Object.entries(config.tasks ?? {})
157
- if (tasks.length === 0) return `${head}\n (no tasks declared)\n`
186
+ const tasks = Object.entries(config?.tasks ?? {})
187
+ if (tasks.length === 0)
188
+ return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
158
189
  const blocks = tasks.map(([taskName, task]) => taskBlock(taskName, task))
159
190
  return `${head}\n\n${blocks.join('\n')}`
160
191
  }
@@ -173,27 +204,71 @@ function renderTask(
173
204
  return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
174
205
  }
175
206
 
207
+ function renderTaskAcross(
208
+ root: string,
209
+ declaring: readonly ProjectEntry[],
210
+ taskName: string,
211
+ format: 'pretty' | 'json',
212
+ ): string {
213
+ if (format === 'json') {
214
+ const list = declaring.map((p) => ({
215
+ name: p.name,
216
+ dir: relPosix(root, p.dir) || '.',
217
+ task: taskName,
218
+ config: p.config.tasks![taskName],
219
+ }))
220
+ return `${JSON.stringify(JSON.parse(JSON.stringify(list)), null, 2)}\n`
221
+ }
222
+ return declaring
223
+ .map(
224
+ (p) =>
225
+ `${p.name} — ${relPosix(root, p.dir) || '.'}\n\n${taskBlock(taskName, p.config.tasks![taskName]!)}`,
226
+ )
227
+ .join('\n')
228
+ }
229
+
230
+ /** Every field the run reads, in the order the schema declares them. */
176
231
  function taskBlock(taskName: string, task: TaskConfig): string {
177
232
  const rows: [string, string][] = []
178
- if (task.description !== undefined) rows.push(['description', task.description])
179
- rows.push(['command', task.exec?.command ?? '(group)'])
180
- if (task.dependsOn !== undefined) rows.push(['dependsOn', task.dependsOn.join(', ')])
181
- if (task.cache !== undefined) {
182
- rows.push(['inputs.files', task.cache.inputs.files.join(', ')])
183
- if (task.cache.inputs.env !== undefined) {
184
- rows.push(['inputs.env', task.cache.inputs.env.join(', ')])
185
- }
186
- if (task.cache.inputs.tasks !== undefined) {
187
- rows.push(['inputs.tasks', task.cache.inputs.tasks.join(', ')])
188
- }
189
- rows.push(['outputs.files', task.cache.outputs.files.join(', ')])
233
+ const list = (xs: readonly string[] | undefined): string | undefined =>
234
+ xs === undefined ? undefined : xs.join(', ')
235
+ const add = (label: string, value: string | number | boolean | undefined): void => {
236
+ if (value !== undefined) rows.push([label, String(value)])
190
237
  }
191
- if (task.exec?.timeout !== undefined) rows.push(['timeout', `${task.exec.timeout}ms`])
192
- const persistent = task.exec?.persistent
238
+ add('description', task.description)
239
+ const exec = task.exec
240
+ rows.push(['command', exec?.command ?? '(group)'])
241
+ add('dependsOn', list(task.dependsOn))
242
+ add('timeout', exec?.timeout === undefined ? undefined : `${exec.timeout}ms`)
243
+ add('retries', exec?.retries)
244
+ add('env.passThrough', list(exec?.env?.passThrough))
245
+ add(
246
+ 'env.define',
247
+ exec?.env?.define === undefined
248
+ ? undefined
249
+ : Object.entries(exec.env.define)
250
+ .map(([k, v]) => `${k}=${v}`)
251
+ .join(', '),
252
+ )
253
+ add('remote', exec?.remote === undefined ? undefined : String(exec.remote))
254
+ add('sandbox', exec?.sandbox === undefined ? undefined : JSON.stringify(exec.sandbox))
255
+ const persistent = exec?.persistent
193
256
  if (persistent !== undefined) {
194
- const fields: string[] = []
195
- if (persistent.readyWhen !== undefined) fields.push(`readyWhen: ${persistent.readyWhen}`)
196
- rows.push(['persistent', fields.length > 0 ? fields.join(', ') : 'yes'])
257
+ add(
258
+ 'persistent',
259
+ persistent.readyWhen === undefined ? 'yes' : `readyWhen: ${persistent.readyWhen}`,
260
+ )
261
+ }
262
+ const cache = task.cache
263
+ if (cache !== undefined) {
264
+ add('inputs.files', list(cache.inputs.files))
265
+ add('inputs.workspaceFiles', list(cache.inputs.workspaceFiles))
266
+ add('inputs.env', list(cache.inputs.env))
267
+ add('inputs.tasks', list(cache.inputs.tasks))
268
+ add('inputs.runtime', list(cache.inputs.runtime))
269
+ add('inputs.workspaceRuntime', list(cache.inputs.workspaceRuntime))
270
+ add('outputs.files', list(cache.outputs.files))
271
+ add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
197
272
  }
198
273
  const labelW = Math.max(...rows.map(([label]) => label.length))
199
274
  const body = rows.map(([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value}`)