@vzn/vx 0.0.21 → 0.0.23
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -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/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cli/last.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
8
8
|
import { formatBytes } from './format.js'
|
|
9
9
|
import { seeHelp } from './help.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
exitSignal,
|
|
12
|
+
getInvocation,
|
|
13
|
+
getRun,
|
|
14
|
+
listInvocations,
|
|
15
|
+
type RunSummaryRow,
|
|
16
|
+
} from '../orchestrator/index.js'
|
|
11
17
|
import { UserError } from '../util/index.js'
|
|
12
18
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
13
19
|
import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
@@ -26,7 +32,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
26
32
|
for (let i = 0; i < args.length; i++) {
|
|
27
33
|
const a = args[i]!
|
|
28
34
|
if (a === '--list' || a.startsWith('--list=')) {
|
|
29
|
-
|
|
35
|
+
// `--list 5` is the count, like every other value flag's space form:
|
|
36
|
+
// it read as a run id, which `--list` then ignored, and ten runs came
|
|
37
|
+
// back (item 899). A run id is never a bare integer, so the next
|
|
38
|
+
// argument is taken only when it is one.
|
|
39
|
+
const next = args[i + 1]
|
|
40
|
+
const spaced = a === '--list' && next !== undefined && /^\d+$/.test(next)
|
|
41
|
+
const lv = a === '--list' ? (spaced ? next : '10') : a.slice(7)
|
|
42
|
+
if (spaced) i++
|
|
30
43
|
const n = Number(lv)
|
|
31
44
|
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
32
45
|
return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
|
|
@@ -53,6 +66,13 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
53
66
|
if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
54
67
|
out.runId = a
|
|
55
68
|
}
|
|
69
|
+
// One run's replay or a list of runs, not both: the id was dropped.
|
|
70
|
+
if (out.runId !== undefined && out.list !== undefined) {
|
|
71
|
+
return {
|
|
72
|
+
...out,
|
|
73
|
+
error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
56
76
|
return out
|
|
57
77
|
}
|
|
58
78
|
|
|
@@ -83,12 +103,84 @@ function fmtUsage(t: {
|
|
|
83
103
|
return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Why a row failed or was skipped, as the run's own footer said it (the
|
|
108
|
+
* v27 columns): a timeout or a never-ready server instead of the signal
|
|
109
|
+
* their exit stands for, the sandbox's violation count, a skip's blocker.
|
|
110
|
+
* Older rows carry none and read as before.
|
|
111
|
+
*/
|
|
112
|
+
function reasonParts(t: RunSummaryRow): string {
|
|
113
|
+
const parts: string[] = []
|
|
114
|
+
if (t.status === 'failed') {
|
|
115
|
+
if (t.notReady !== null) {
|
|
116
|
+
parts.push(
|
|
117
|
+
`never ready: ${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}`,
|
|
118
|
+
)
|
|
119
|
+
} else if (t.timedOut === true) {
|
|
120
|
+
parts.push('timed out')
|
|
121
|
+
} else {
|
|
122
|
+
const signal = exitSignal(t.exitCode)
|
|
123
|
+
if (signal !== undefined) parts.push(`128 + ${signal}`)
|
|
124
|
+
}
|
|
125
|
+
if (t.sandboxViolations !== null && t.sandboxViolations > 0) {
|
|
126
|
+
parts.push(`${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}`)
|
|
127
|
+
}
|
|
128
|
+
} else if (t.status === 'skipped' && t.blockedBy !== null) {
|
|
129
|
+
parts.push(`after ${t.blockedBy} failed`)
|
|
130
|
+
}
|
|
131
|
+
return parts.map((p) => ` ${p}`).join('')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Hit rows shown before the rest fold — the slowest restores. */
|
|
135
|
+
const HITS_SHOWN = 16
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The per-task rows: failures first, then what executed or was skipped,
|
|
139
|
+
* then cache hits. Hits are the noise of a warm run — 996 of a thousand
|
|
140
|
+
* rows on a red run put the failure a screen's height above the prompt
|
|
141
|
+
* (item 280) — so past `HITS_SHOWN` they fold into one line with their
|
|
142
|
+
* count; the ones shown are the slowest restores, the one thing a hit's
|
|
143
|
+
* row tells. `--format json` lists every row.
|
|
144
|
+
*/
|
|
145
|
+
export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
146
|
+
if (tasks.length === 0) return []
|
|
147
|
+
const failed = tasks.filter((t) => t.status === 'failed')
|
|
148
|
+
const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
|
|
149
|
+
const rest = tasks.filter((t) => t.status !== 'failed' && t.cacheHit !== true)
|
|
150
|
+
const folded = hits.length > HITS_SHOWN
|
|
151
|
+
const shownHits = folded
|
|
152
|
+
? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
|
|
153
|
+
: hits
|
|
154
|
+
const ordered = [...failed, ...rest, ...shownHits]
|
|
155
|
+
const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
|
|
156
|
+
const lines = ['']
|
|
157
|
+
for (const t of ordered) {
|
|
158
|
+
const id = `${t.project}#${t.task}`
|
|
159
|
+
// The terminal summary's own word for a task that runs every time
|
|
160
|
+
// by design; a reader must not take its row for a miss.
|
|
161
|
+
// A failure's row reads as the frame did — `failed (exit 137)` —
|
|
162
|
+
// and above 128 names the signal the number stands for (260).
|
|
163
|
+
const status = t.status === 'failed' ? `failed (exit ${t.exitCode})` : t.status
|
|
164
|
+
lines.push(
|
|
165
|
+
` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
166
|
+
`${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
|
|
167
|
+
reasonParts(t),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (folded) {
|
|
171
|
+
lines.push(
|
|
172
|
+
` … +${hits.length - HITS_SHOWN} more cache hits (the ${HITS_SHOWN} slowest restores shown) — vx last --format json lists every row`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return lines
|
|
176
|
+
}
|
|
177
|
+
|
|
86
178
|
export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
87
179
|
const parsed = parseLastArgs(args)
|
|
88
180
|
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
|
|
89
181
|
|
|
90
182
|
const root = await findWorkspaceRoot(process.cwd())
|
|
91
|
-
const cache =
|
|
183
|
+
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
92
184
|
noteSchemaReset(cache, warnToStderr)
|
|
93
185
|
try {
|
|
94
186
|
const db = cache.dbHandle()
|
|
@@ -146,24 +238,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
146
238
|
` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
|
|
147
239
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
148
240
|
)
|
|
149
|
-
|
|
150
|
-
if (tasks.length > 0) {
|
|
151
|
-
lines.push('')
|
|
152
|
-
const failedFirst = [
|
|
153
|
-
...tasks.filter((t) => t.status === 'failed'),
|
|
154
|
-
...tasks.filter((t) => t.status !== 'failed'),
|
|
155
|
-
]
|
|
156
|
-
const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
|
|
157
|
-
for (const t of failedFirst) {
|
|
158
|
-
const id = `${t.project}#${t.task}`
|
|
159
|
-
// The terminal summary's own word for a task that runs every time
|
|
160
|
-
// by design; a reader must not take its row for a miss.
|
|
161
|
-
lines.push(
|
|
162
|
-
` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
163
|
-
`${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}`,
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
241
|
+
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
|
167
242
|
process.stdout.write(`${lines.join('\n')}\n`)
|
|
168
243
|
return 0
|
|
169
244
|
} finally {
|
package/src/cli/lock.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { seeHelp } from './help.js'
|
|
|
7
7
|
import { relPosix, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
10
|
+
type LoadReads,
|
|
10
11
|
listProjects,
|
|
11
12
|
loadProjectConfig,
|
|
12
13
|
loadWorkspace,
|
|
@@ -41,32 +42,42 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
41
42
|
process.stderr.write(`vx lock: ${parsed.error}\n`)
|
|
42
43
|
return 1
|
|
43
44
|
}
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
const reads: LoadReads = new Map()
|
|
46
|
+
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
47
|
+
const workspace = await loadWorkspace(root, reads)
|
|
48
|
+
const all = await listProjects(workspace)
|
|
49
|
+
const metas = all.filter((m): m is ConfiguredMeta => m.configPath !== null)
|
|
50
|
+
// A project with no vx.config has nothing to freeze: its tasks (a plugin's,
|
|
51
|
+
// or none) load live under --frozen too, so both verbs say how many the
|
|
52
|
+
// count above leaves out — `locked 0 project configs` on a turbo() repo
|
|
53
|
+
// read like an audit of something (2026-09-16).
|
|
54
|
+
const bare = all.length - metas.length
|
|
55
|
+
if (parsed.check) return await checkLock(root, metas, bare)
|
|
56
|
+
return await writeLock(root, metas, bare)
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/** Evaluate one config in the current env, returning its lock entry. */
|
|
54
60
|
async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
|
|
55
61
|
const bytes = await Bun.file(meta.configPath).bytes()
|
|
56
|
-
// `fresh: true` —
|
|
57
|
-
// a
|
|
62
|
+
// `fresh: true` — the lock freezes this environment's evaluation, never
|
|
63
|
+
// a stored one, whatever options a later edit passes beside it.
|
|
58
64
|
const config = await loadProjectConfig(meta.configPath, { fresh: true })
|
|
59
65
|
return {
|
|
60
66
|
configPath: relPosix(root, meta.configPath),
|
|
61
67
|
configHash: xxh3hex(bytes),
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
config: JSON.parse(JSON.stringify(config)) as ProjectConfig,
|
|
68
|
+
// Stored through `writeLockfile`'s JSON.stringify, which drops a field
|
|
69
|
+
// left `undefined` — the form `--check` compares a fresh evaluation in.
|
|
70
|
+
config,
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
function bareNote(bare: number): string {
|
|
75
|
+
return bare === 0
|
|
76
|
+
? ''
|
|
77
|
+
: ` (${bare} project${bare === 1 ? ' has' : 's have'} no vx.config; their tasks are never frozen)`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
70
81
|
const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
|
|
71
82
|
const projects: Record<string, LockfileEntry> = {}
|
|
72
83
|
// `listProjects` sorts by name — stable lockfile diffs for free.
|
|
@@ -74,7 +85,9 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
74
85
|
const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
|
|
75
86
|
await writeLockfile(root, lock)
|
|
76
87
|
const n = metas.length
|
|
77
|
-
process.stdout.write(
|
|
88
|
+
process.stdout.write(
|
|
89
|
+
`vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
|
|
90
|
+
)
|
|
78
91
|
return 0
|
|
79
92
|
}
|
|
80
93
|
|
|
@@ -86,7 +99,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
86
99
|
* catches eval-time env-var drift that file hashes cannot see —
|
|
87
100
|
* file bytes unchanged, resolved value changed.
|
|
88
101
|
*/
|
|
89
|
-
async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
|
|
102
|
+
async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
90
103
|
const lock = await readLockfile(root)
|
|
91
104
|
if (!lock) {
|
|
92
105
|
process.stderr.write(
|
|
@@ -105,6 +118,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
105
118
|
if (hash !== entry.configHash) {
|
|
106
119
|
return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
|
|
107
120
|
}
|
|
121
|
+
// The lock's form: a first load is the module's live export, where a
|
|
122
|
+
// field left `undefined` is a key the stored JSON never has.
|
|
108
123
|
const fresh = JSON.parse(
|
|
109
124
|
JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
|
|
110
125
|
) as ProjectConfig
|
|
@@ -131,6 +146,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
|
|
|
131
146
|
return 1
|
|
132
147
|
}
|
|
133
148
|
const n = metas.length
|
|
134
|
-
|
|
149
|
+
const audited = `${n} project${n === 1 ? '' : 's'}`
|
|
150
|
+
const skipped = bare === 0 ? '' : `; ${bare} without a vx.config not audited`
|
|
151
|
+
process.stdout.write(`vx: lock is up to date (${audited}${skipped})\n`)
|
|
135
152
|
return 0
|
|
136
153
|
}
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -9,12 +9,12 @@ import { formatDuration } from '../orchestrator/index.js'
|
|
|
9
9
|
* task with recorded history also shows its typical duration (~p50).
|
|
10
10
|
*
|
|
11
11
|
* would run:
|
|
12
|
-
*
|
|
12
|
+
* ◉ @vzn/vx#lint cache hit (local) abc12345
|
|
13
13
|
* ↓ @vzn/vx#test cache hit (remote) def67890
|
|
14
|
-
* ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.
|
|
14
|
+
* ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
|
|
15
15
|
*
|
|
16
|
-
* 3 task(s) planned
|
|
17
|
-
* predicted: ~1.
|
|
16
|
+
* 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
|
|
17
|
+
* predicted: ~1.20s wall · ~1.20s total execution
|
|
18
18
|
*/
|
|
19
19
|
export function formatPlanText(plan: RunPlan): string {
|
|
20
20
|
const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
|
|
@@ -17,6 +17,17 @@ export interface ResolvedPluginCommand {
|
|
|
17
17
|
ctx: CommandContext
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* No plugin here declares the verb, and these are the verbs they DO declare
|
|
22
|
+
* — the same load that failed to find the verb already knows them, so the
|
|
23
|
+
* "did you mean" and the "where verbs come from" line cost nothing extra.
|
|
24
|
+
* An empty list is a workspace whose plugins declare no verb at all; `null`
|
|
25
|
+
* from the lookup is no workspace.
|
|
26
|
+
*/
|
|
27
|
+
interface NoPluginCommand {
|
|
28
|
+
declaredVerbs: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* Why a verb could not be looked up: the workspace file failed to load.
|
|
22
33
|
* Surfaced next to "unknown command" rather than as the whole answer — a
|
|
@@ -50,17 +61,21 @@ async function workspacePlugins(
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
/**
|
|
53
|
-
* The plugin verb named `verb` for the workspace around `cwd`;
|
|
54
|
-
*
|
|
64
|
+
* The plugin verb named `verb` for the workspace around `cwd`; the verbs
|
|
65
|
+
* this workspace's plugins do declare when none of them is it; null when
|
|
66
|
+
* there is no workspace; the load error when the workspace file could not
|
|
67
|
+
* say.
|
|
55
68
|
*/
|
|
56
69
|
export async function resolvePluginCommand(
|
|
57
70
|
verb: string,
|
|
58
71
|
cwd = process.cwd(),
|
|
59
|
-
): Promise<ResolvedPluginCommand | UnresolvedPluginCommand | null> {
|
|
72
|
+
): Promise<ResolvedPluginCommand | NoPluginCommand | UnresolvedPluginCommand | null> {
|
|
60
73
|
const ws = await workspacePlugins(cwd)
|
|
61
74
|
if (ws === null) return null
|
|
62
75
|
if ('loadError' in ws) return ws
|
|
76
|
+
const declaredVerbs = new Set<string>()
|
|
63
77
|
for (const plugin of ws.plugins) {
|
|
78
|
+
for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
|
|
64
79
|
const command = plugin.commands?.[verb]
|
|
65
80
|
if (command === undefined) continue
|
|
66
81
|
return {
|
|
@@ -73,10 +88,9 @@ export async function resolvePluginCommand(
|
|
|
73
88
|
},
|
|
74
89
|
}
|
|
75
90
|
}
|
|
76
|
-
return
|
|
91
|
+
return { declaredVerbs: [...declaredVerbs] }
|
|
77
92
|
}
|
|
78
93
|
|
|
79
|
-
/** `vx help` lines for every plugin verb in the workspace around `cwd`. */
|
|
80
94
|
/** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
|
|
81
95
|
export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
|
|
82
96
|
const ws = await workspacePlugins(cwd).catch(() => null)
|
|
@@ -87,6 +101,7 @@ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
|
|
|
87
101
|
return [...verbs]
|
|
88
102
|
}
|
|
89
103
|
|
|
104
|
+
/** `vx help` lines for every plugin verb in the workspace around `cwd`. */
|
|
90
105
|
export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
|
|
91
106
|
const ws = await workspacePlugins(cwd).catch(() => null)
|
|
92
107
|
if (ws === null || 'loadError' in ws) return []
|
package/src/cli/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFile } from 'node:fs/promises'
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { documentedFlags, seeHelp } from './help.js'
|
|
4
4
|
import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
|
|
@@ -15,7 +15,13 @@ import {
|
|
|
15
15
|
import type { ContinueMode } from '../graph/index.js'
|
|
16
16
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
17
17
|
import { findCwdProject, pickTask, resolveFilters } from './select.js'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
MAX_TIMEOUT_MS,
|
|
20
|
+
isUserError,
|
|
21
|
+
parseDecimalInt,
|
|
22
|
+
nearest,
|
|
23
|
+
machineParallelism,
|
|
24
|
+
} from '../util/index.js'
|
|
19
25
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
20
26
|
|
|
21
27
|
export interface RunArgs {
|
|
@@ -359,11 +365,21 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
359
365
|
if (out.dry !== undefined && out.graph !== undefined) {
|
|
360
366
|
return { ...out, error: '--dry and --graph are mutually exclusive' }
|
|
361
367
|
}
|
|
362
|
-
|
|
363
|
-
|
|
368
|
+
// Every flag that promises a file or a section about the run: the
|
|
369
|
+
// planning branch returns before any of them is written, and
|
|
370
|
+
// `--report-file="$GITHUB_STEP_SUMMARY" --dry` exited 0 having written
|
|
371
|
+
// nothing (item 992).
|
|
372
|
+
const needsRun =
|
|
373
|
+
out.summarize !== undefined ||
|
|
374
|
+
out.profile !== undefined ||
|
|
375
|
+
out.report !== undefined ||
|
|
376
|
+
out.reportFile !== undefined
|
|
377
|
+
const runOnly = '--summarize / --profile / --report / --report-file need a real run'
|
|
378
|
+
if (out.dry !== undefined && needsRun) {
|
|
379
|
+
return { ...out, error: `--dry skips execution; ${runOnly}` }
|
|
364
380
|
}
|
|
365
|
-
if (out.graph !== undefined &&
|
|
366
|
-
return { ...out, error:
|
|
381
|
+
if (out.graph !== undefined && needsRun) {
|
|
382
|
+
return { ...out, error: `--graph skips execution; ${runOnly}` }
|
|
367
383
|
}
|
|
368
384
|
return out
|
|
369
385
|
}
|
|
@@ -415,14 +431,28 @@ export async function resolveRunOptions(
|
|
|
415
431
|
}
|
|
416
432
|
}
|
|
417
433
|
|
|
418
|
-
// `--affected[=<base>]` is sugar for `--filter '[<base>]'
|
|
419
|
-
//
|
|
420
|
-
//
|
|
434
|
+
// `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
|
|
435
|
+
// changed projects AND their dependents — what a CI gate must run, what
|
|
436
|
+
// the flag's name says, what the guides promised while the sugar was the
|
|
437
|
+
// changed-only `[<base>]` (item 287). `--filter '[<base>]'` stays the
|
|
438
|
+
// "only what I touched" form. Merging it into the filter list means the
|
|
439
|
+
// same code path handles plain filter use, --affected alone, and the combo.
|
|
440
|
+
// It goes FIRST: filters apply in argv order, and appended last it came
|
|
441
|
+
// after every `!` exclude, so `--affected --filter '!app'` still ran app
|
|
442
|
+
// (item 955). An include's place does not change a union.
|
|
421
443
|
const filterStrings = [...parsed.filters]
|
|
422
444
|
if (parsed.affected !== undefined) {
|
|
423
445
|
const root = await findWorkspaceRoot(cwd)
|
|
424
|
-
|
|
425
|
-
|
|
446
|
+
let base = parsed.affected
|
|
447
|
+
if (base === '') {
|
|
448
|
+
try {
|
|
449
|
+
base = await defaultAffectedBase(root)
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (!isUserError(err)) throw err
|
|
452
|
+
return { error: err.message }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
filterStrings.unshift(`...[${base}]`)
|
|
426
456
|
}
|
|
427
457
|
|
|
428
458
|
// Project scope applies to bare task names only. Anchored entries
|
|
@@ -560,7 +590,17 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
560
590
|
if (parsed.graph === '') {
|
|
561
591
|
process.stdout.write(out)
|
|
562
592
|
} else {
|
|
563
|
-
|
|
593
|
+
// The graph is the command's one product, so a path it cannot write
|
|
594
|
+
// is exit 1, said in one line as the other output paths say theirs:
|
|
595
|
+
// a directory or an unreachable path printed a raw stack (item 993).
|
|
596
|
+
const target = path.resolve(cwd, parsed.graph)
|
|
597
|
+
try {
|
|
598
|
+
await Bun.write(target, out)
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
601
|
+
process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
|
|
602
|
+
return 1
|
|
603
|
+
}
|
|
564
604
|
}
|
|
565
605
|
} else if (parsed.dry === 'json') {
|
|
566
606
|
process.stdout.write(formatPlanJson(plan))
|
|
@@ -595,6 +635,9 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
595
635
|
// same job also write to. Truncating it would silently destroy their
|
|
596
636
|
// content, and would make replacing the documented `>>` recipe a
|
|
597
637
|
// behaviour change rather than a drop-in.
|
|
638
|
+
// Its directory made first, as `--summarize`, `--profile` and
|
|
639
|
+
// `--graph` make theirs: `nr/r.md` failed alone of the four (item 993).
|
|
640
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
598
641
|
await appendFile(target, md)
|
|
599
642
|
} catch (err) {
|
|
600
643
|
// Same contract as --summarize / --profile: the run already
|
package/src/cli/select.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// What a run is asked to run: the projects a `--filter` selects, the
|
|
2
|
-
// ones `--affected` touches (with the
|
|
2
|
+
// ones `--affected` touches (with the `workspaceFiles` owners), the project the
|
|
3
3
|
// cwd sits in, and the interactive picker. Every read of the workspace
|
|
4
4
|
// here goes through the staged load, so the answer is the run's.
|
|
5
5
|
|
|
@@ -7,9 +7,11 @@ import readline from 'node:readline/promises'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import {
|
|
9
9
|
affectedProjects,
|
|
10
|
+
refIsHead,
|
|
10
11
|
applyFilters,
|
|
11
12
|
buildPackageGraph,
|
|
12
13
|
findWorkspaceRoot,
|
|
14
|
+
type LoadReads,
|
|
13
15
|
type FingerprintClaims,
|
|
14
16
|
FROZEN_WITHOUT_LOCK,
|
|
15
17
|
listProjects,
|
|
@@ -29,8 +31,8 @@ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './worksp
|
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
|
|
32
|
-
* matching
|
|
33
|
-
* selects them, since the glob is that task's input. Through the run
|
|
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
|
|
34
36
|
* path's staged load, so a glob a `project` plugin gave a config-less
|
|
35
37
|
* package counts; evaluated live as a default run does, or read from the
|
|
36
38
|
* lock as a `--frozen` run does. A load that fails drops to the config files that
|
|
@@ -40,7 +42,7 @@ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './worksp
|
|
|
40
42
|
export async function workspaceGlobOwners(
|
|
41
43
|
root: string,
|
|
42
44
|
projects: readonly ProjectMeta[],
|
|
43
|
-
|
|
45
|
+
changed: readonly string[],
|
|
44
46
|
load: CliLoadOptions = {},
|
|
45
47
|
stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
|
|
46
48
|
loadCliProjects(root, projects, 'all', load),
|
|
@@ -49,7 +51,7 @@ export async function workspaceGlobOwners(
|
|
|
49
51
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
50
52
|
const globs = task.cache?.inputs?.workspaceFiles
|
|
51
53
|
if (globs === undefined) continue
|
|
52
|
-
if (
|
|
54
|
+
if (changed.some((rel) => workspaceGlobsMatch(globs, rel))) return true
|
|
53
55
|
}
|
|
54
56
|
return false
|
|
55
57
|
}
|
|
@@ -104,10 +106,10 @@ async function workspaceFingerprintClaims(
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
return await listProjects(
|
|
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))
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export async function findCwdProject(cwd: string): Promise<string | null> {
|
|
@@ -180,7 +182,7 @@ export async function resolveFilters(
|
|
|
180
182
|
const parsed = raw.map((r) => parseFilter(r, root))
|
|
181
183
|
const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
|
|
182
184
|
// Every reader of the staged configs in this pass — the `pkg#task`
|
|
183
|
-
// edge walk, the `workspaceFiles` owners of
|
|
185
|
+
// edge walk, the `workspaceFiles` owners of a changed path — shares
|
|
184
186
|
// ONE load, and the run reuses it (`RunOptions.staged`): the `project`
|
|
185
187
|
// stage runs once per project per run.
|
|
186
188
|
let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
|
|
@@ -207,8 +209,8 @@ export async function resolveFilters(
|
|
|
207
209
|
workspaceRoot: root,
|
|
208
210
|
since: f.gitSince,
|
|
209
211
|
projects,
|
|
210
|
-
workspaceGlobOwners: (
|
|
211
|
-
workspaceGlobOwners(root, projects,
|
|
212
|
+
workspaceGlobOwners: (changed) =>
|
|
213
|
+
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
212
214
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
213
215
|
})
|
|
214
216
|
affectedByFilter.set(f, names)
|
|
@@ -238,7 +240,15 @@ export async function resolveFilters(
|
|
|
238
240
|
const includes = parsed.filter((f) => !f.negate)
|
|
239
241
|
if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
|
|
240
242
|
const refs = includes.map((f) => f.gitSince).join(', ')
|
|
241
|
-
|
|
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}` }
|
|
242
252
|
}
|
|
243
253
|
// One line, not a warning per pattern and then an error saying the same:
|
|
244
254
|
// the patterns are in the error, and the nearest project name is the
|
package/src/cli/show.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
// Deliberately NOT the lock — vx-lock.json is already the frozen JSON.
|
|
8
8
|
|
|
9
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
10
|
+
import { declaredTask } from '../graph/index.js'
|
|
10
11
|
import { seeHelp } from './help.js'
|
|
11
12
|
import { nearMatches, relPosix, UserError } from '../util/index.js'
|
|
12
13
|
import { loadCliProjects } from './workspace-config.js'
|
|
13
14
|
import {
|
|
14
15
|
findWorkspaceRoot,
|
|
16
|
+
type LoadReads,
|
|
15
17
|
listProjects,
|
|
16
18
|
loadWorkspace,
|
|
17
19
|
type ProjectEntry,
|
|
@@ -51,8 +53,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
51
53
|
process.stderr.write(`vx show: ${parsed.error}\n`)
|
|
52
54
|
return 1
|
|
53
55
|
}
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
+
const reads: LoadReads = new Map()
|
|
57
|
+
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
58
|
+
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
56
59
|
const byName = new Map(metas.map((m) => [m.name, m]))
|
|
57
60
|
|
|
58
61
|
const hashAt = parsed.target?.indexOf('#') ?? -1
|
|
@@ -84,7 +87,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
if (bareTask) {
|
|
87
|
-
const declaring = [...projects.values()].filter(
|
|
90
|
+
const declaring = [...projects.values()].filter(
|
|
91
|
+
(p) => declaredTask(p.config, projectName!) !== undefined,
|
|
92
|
+
)
|
|
88
93
|
if (declaring.length === 0) {
|
|
89
94
|
const names = new Set<string>()
|
|
90
95
|
for (const p of projects.values())
|
|
@@ -109,7 +114,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
109
114
|
return 0
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
const task = config
|
|
117
|
+
const task = declaredTask(config, taskName)
|
|
113
118
|
if (task === undefined) {
|
|
114
119
|
throw new UserError(
|
|
115
120
|
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,
|