@vzn/vx 0.0.18 → 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.
- package/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- 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-turbo.ts +0 -401
- 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/schedule-history/index.ts +0 -109
|
@@ -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
|
-
//
|
|
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 { 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(
|
|
82
|
+
process.stdout.write(renderList(root, metas, projects, parsed.format))
|
|
56
83
|
return 0
|
|
57
84
|
}
|
|
58
85
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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 =
|
|
125
|
+
const near = new Set(nearMatches(query, candidates))
|
|
126
|
+
for (const c of candidates) {
|
|
98
127
|
const n = c.toLowerCase()
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
return near.
|
|
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
|
-
|
|
138
|
+
function renderList(
|
|
110
139
|
root: string,
|
|
111
140
|
metas: readonly ProjectMeta[],
|
|
141
|
+
projects: ReadonlyMap<string, ProjectEntry>,
|
|
112
142
|
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
|
-
)
|
|
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
|
-
?
|
|
137
|
-
:
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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(', ')])
|
|
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
|
-
|
|
192
|
-
const
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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}`)
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
6
6
|
|
|
7
7
|
import { chmod, rename, rm } from 'node:fs/promises'
|
|
8
|
+
import { seeHelp } from './help.js'
|
|
8
9
|
import { UserError } from '../util/index.js'
|
|
9
10
|
import { VERSION } from '../version.js'
|
|
10
11
|
|
|
@@ -77,7 +78,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
77
78
|
const tag = args.find((a) => !a.startsWith('-'))
|
|
78
79
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
79
80
|
if (unknown !== undefined) {
|
|
80
|
-
process.stderr.write(`vx upgrade: unknown flag: ${unknown}\n`)
|
|
81
|
+
process.stderr.write(`vx upgrade: unknown flag: ${unknown}${seeHelp('upgrade')}\n`)
|
|
81
82
|
return 1
|
|
82
83
|
}
|
|
83
84
|
if (!isCompiledBinary()) {
|