@vzn/vx 0.0.18 → 0.0.22

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