@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
|
52
|
-
const
|
|
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(
|
|
85
|
+
process.stdout.write(renderList(root, metas, projects, parsed.format))
|
|
56
86
|
return 0
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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 =
|
|
130
|
+
const near = new Set(nearMatches(query, candidates))
|
|
131
|
+
for (const c of candidates) {
|
|
98
132
|
const n = c.toLowerCase()
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
return near.
|
|
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
|
-
|
|
143
|
+
function renderList(
|
|
110
144
|
root: string,
|
|
111
145
|
metas: readonly ProjectMeta[],
|
|
146
|
+
projects: ReadonlyMap<string, ProjectEntry>,
|
|
112
147
|
format: 'pretty' | 'json',
|
|
113
|
-
):
|
|
114
|
-
const rows =
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
?
|
|
137
|
-
:
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
192
|
-
const
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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}`)
|