@vzn/vx 0.0.15 → 0.0.18
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 +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cli/run.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import readline from 'node:readline/promises'
|
|
2
|
+
import { appendFile } from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
4
|
+
import { documentedFlags } from './help.js'
|
|
3
5
|
import {
|
|
4
6
|
affectedProjects,
|
|
5
7
|
applyFilters,
|
|
@@ -9,26 +11,25 @@ import {
|
|
|
9
11
|
listProjects,
|
|
10
12
|
loadProjectConfig,
|
|
11
13
|
loadWorkspace,
|
|
12
|
-
loadWorkspaceConfig,
|
|
13
14
|
parseFilter,
|
|
14
|
-
|
|
15
|
+
readLockfile,
|
|
16
|
+
workspaceGlobsMatch,
|
|
15
17
|
type ProjectMeta,
|
|
16
18
|
} from '../workspace/index.js'
|
|
17
19
|
import {
|
|
18
20
|
planRun,
|
|
19
21
|
formatRunReportMarkdown,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
projectOutcome,
|
|
23
|
+
run as runOrchestrator,
|
|
22
24
|
type OutcomeView,
|
|
23
25
|
type RunOptions,
|
|
24
26
|
type RunResult,
|
|
25
|
-
type VxPlugin,
|
|
26
27
|
} from '../orchestrator/index.js'
|
|
28
|
+
import type { ProjectConfig } from '../config.js'
|
|
27
29
|
import type { ContinueMode } from '../graph/index.js'
|
|
28
30
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
29
|
-
import { parseSize } from '../util/index.js'
|
|
31
|
+
import { MAX_TIMEOUT_MS, parseDecimalInt, parseSize, editDistance } from '../util/index.js'
|
|
30
32
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
31
|
-
import { localBackend } from './backend.js'
|
|
32
33
|
|
|
33
34
|
export interface RunArgs {
|
|
34
35
|
/** Failure propagation (`--continue[=never|deps-ok|always]`). */
|
|
@@ -85,7 +86,8 @@ export interface RunArgs {
|
|
|
85
86
|
*/
|
|
86
87
|
verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
|
|
87
88
|
verifyAllow: string[]
|
|
88
|
-
outputLogs?: 'full' | 'errors-only' | 'none'
|
|
89
|
+
outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
|
|
90
|
+
download?: 'all' | 'toplevel' | 'none'
|
|
89
91
|
forwardArgs: string[]
|
|
90
92
|
verbosity: number
|
|
91
93
|
dry: 'text' | 'json' | undefined
|
|
@@ -102,6 +104,12 @@ export interface RunArgs {
|
|
|
102
104
|
tags: Record<string, string>
|
|
103
105
|
/** `--report[=markdown]`. Undefined when the flag wasn't passed. */
|
|
104
106
|
report: 'markdown' | undefined
|
|
107
|
+
/**
|
|
108
|
+
* `--report-file=<path>`. Undefined when the flag wasn't passed. Writes
|
|
109
|
+
* the same markdown as `--report`, but to a file — because redirecting
|
|
110
|
+
* vx's stdout captures the whole run log, not just the report.
|
|
111
|
+
*/
|
|
112
|
+
reportFile: string | undefined
|
|
105
113
|
error?: string
|
|
106
114
|
}
|
|
107
115
|
|
|
@@ -129,6 +137,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
129
137
|
affected: undefined,
|
|
130
138
|
tags: {},
|
|
131
139
|
report: undefined,
|
|
140
|
+
reportFile: undefined,
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
const sepIdx = args.indexOf('--')
|
|
@@ -146,22 +155,34 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
146
155
|
|
|
147
156
|
for (let i = 0; i < before.length; i++) {
|
|
148
157
|
const a = before[i]
|
|
149
|
-
if (a === '--filter') {
|
|
150
|
-
const v = before[++i]
|
|
151
|
-
if (v === undefined) return { ...out, error:
|
|
158
|
+
if (a === '--filter' || a?.startsWith('--filter=')) {
|
|
159
|
+
const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
|
|
160
|
+
if (v === undefined || v === '') return { ...out, error: `--filter requires a value` }
|
|
152
161
|
out.filters.push(v)
|
|
153
|
-
} else if (a === '--concurrency') {
|
|
154
|
-
const v = before[++i]
|
|
155
|
-
if (v === undefined) return { ...out, error:
|
|
156
|
-
const n =
|
|
157
|
-
if (
|
|
158
|
-
out.concurrency =
|
|
162
|
+
} else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
|
|
163
|
+
const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
|
|
164
|
+
if (v === undefined) return { ...out, error: `--concurrency requires a value` }
|
|
165
|
+
const n = parseDecimalInt(v)
|
|
166
|
+
if (n === null || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
|
|
167
|
+
out.concurrency = n
|
|
159
168
|
} else if (a === '--all') {
|
|
160
169
|
out.all = true
|
|
161
170
|
} else if (a === '--excludeDependencies') {
|
|
162
171
|
out.excludeDependencies = 'all'
|
|
163
172
|
} else if (a?.startsWith('--excludeDependencies=')) {
|
|
164
173
|
const raw = a.slice('--excludeDependencies='.length)
|
|
174
|
+
// An empty value is genuinely ambiguous — "drop every edge" (the
|
|
175
|
+
// bare flag) and "drop none" (an empty list) are equally defensible
|
|
176
|
+
// readings, and silently picking either does the opposite of what
|
|
177
|
+
// half the callers mean. Reject and name both explicit forms; the
|
|
178
|
+
// sibling value flags (--retry=, --timeout=, --memory=, --cache-dir=)
|
|
179
|
+
// reject an empty `=` value the same way.
|
|
180
|
+
if (raw === '') {
|
|
181
|
+
return {
|
|
182
|
+
...out,
|
|
183
|
+
error: `--excludeDependencies= needs a value — pass bare --excludeDependencies to drop every dependsOn edge, or omit the flag to keep them`,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
165
186
|
out.excludeDependencies = raw
|
|
166
187
|
.split(',')
|
|
167
188
|
.map((s) => s.trim())
|
|
@@ -171,16 +192,26 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
171
192
|
} else if (a === '--retry' || a?.startsWith('--retry=')) {
|
|
172
193
|
const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
|
|
173
194
|
if (v === undefined) return { ...out, error: `--retry requires a value` }
|
|
174
|
-
const n =
|
|
175
|
-
if (
|
|
195
|
+
const n = parseDecimalInt(v)
|
|
196
|
+
if (n === null) {
|
|
176
197
|
return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
|
|
177
198
|
}
|
|
178
199
|
out.retries = n
|
|
179
200
|
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
180
201
|
const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
|
|
181
202
|
if (v === undefined) return { ...out, error: `--timeout requires a value` }
|
|
182
|
-
const n =
|
|
183
|
-
if (
|
|
203
|
+
const n = parseDecimalInt(v)
|
|
204
|
+
if (n !== null && n > MAX_TIMEOUT_MS) {
|
|
205
|
+
// Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
|
|
206
|
+
// to 1 ms, so this would kill every task the moment it spawned.
|
|
207
|
+
return {
|
|
208
|
+
...out,
|
|
209
|
+
error:
|
|
210
|
+
`--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
|
|
211
|
+
`larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (n === null || n <= 0) {
|
|
184
215
|
return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
|
|
185
216
|
}
|
|
186
217
|
out.timeout = n
|
|
@@ -208,32 +239,56 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
208
239
|
error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
|
|
209
240
|
}
|
|
210
241
|
}
|
|
211
|
-
} else if (a?.startsWith('--verify-allow=')) {
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
} else if (a === '--verify-allow' || a?.startsWith('--verify-allow=')) {
|
|
243
|
+
const v = a === '--verify-allow' ? before[++i] : a.slice('--verify-allow='.length)
|
|
244
|
+
if (v === undefined) return { ...out, error: `--verify-allow requires a value` }
|
|
245
|
+
// A flag-shaped space-form value is always a lost flag (task ids
|
|
246
|
+
// never start with `-`), never a value the user meant.
|
|
247
|
+
if (a === '--verify-allow' && v.startsWith('-')) {
|
|
248
|
+
return { ...out, error: `--verify-allow requires a value, got flag: ${v}` }
|
|
249
|
+
}
|
|
250
|
+
out.verifyAllow = v
|
|
214
251
|
.split(',')
|
|
215
252
|
.map((s) => s.trim())
|
|
216
253
|
.filter((s) => s.length > 0)
|
|
217
|
-
} else if (a === '--output-logs') {
|
|
218
|
-
const v = before[++i]
|
|
219
|
-
if (v !== 'full' && v !== 'errors-only' && v !== 'none') {
|
|
220
|
-
return { ...out, error: `--output-logs must be full, errors-only, or none` }
|
|
254
|
+
} else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
|
|
255
|
+
const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
|
|
256
|
+
if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
|
|
257
|
+
return { ...out, error: `--output-logs must be full, errors-only, hash-only, or none` }
|
|
221
258
|
}
|
|
222
259
|
out.outputLogs = v
|
|
260
|
+
} else if (a === '--download' || a?.startsWith('--download=')) {
|
|
261
|
+
const v = a === '--download' ? before[++i] : a.slice('--download='.length)
|
|
262
|
+
if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
|
|
263
|
+
return { ...out, error: `--download must be all, toplevel, or none` }
|
|
264
|
+
}
|
|
265
|
+
out.download = v
|
|
223
266
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
224
267
|
const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
|
|
225
268
|
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
|
|
269
|
+
// Unlike every other value flag, a cache dir is an arbitrary
|
|
270
|
+
// string — nothing about its shape rejects a swallowed flag. An
|
|
271
|
+
// unquoted empty shell var (`--cache-dir $EMPTY --force`) would
|
|
272
|
+
// otherwise create a directory literally named `--force` and drop
|
|
273
|
+
// the flag. A path starting with `-` needs the `=` form.
|
|
274
|
+
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
275
|
+
return { ...out, error: `--cache-dir requires a path, got flag: ${v}` }
|
|
276
|
+
}
|
|
226
277
|
out.cacheDir = v
|
|
227
|
-
} else if (a === '--cache') {
|
|
228
|
-
const v = before[++i]
|
|
229
|
-
if (v === undefined) return { ...out, error:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
278
|
+
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
279
|
+
const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
|
|
280
|
+
if (v === undefined) return { ...out, error: `--cache requires a value` }
|
|
281
|
+
// A spec with no segment at all applies NOTHING, so `--cache=`
|
|
282
|
+
// (an empty shell var, or someone reaching for "no cache") left
|
|
283
|
+
// all four axes ON — the opposite of the intent. Reject it; an
|
|
284
|
+
// empty flag list for a NAMED layer (`local:`) stays valid and
|
|
285
|
+
// means that layer off.
|
|
286
|
+
if (!v.split(',').some((seg) => seg.trim().length > 0)) {
|
|
287
|
+
return {
|
|
288
|
+
...out,
|
|
289
|
+
error: `--cache needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
|
|
290
|
+
}
|
|
234
291
|
}
|
|
235
|
-
} else if (a?.startsWith('--cache=')) {
|
|
236
|
-
const v = a.slice('--cache='.length)
|
|
237
292
|
try {
|
|
238
293
|
cachePolicy = parseCachePolicy(v, cachePolicy)
|
|
239
294
|
} catch (err) {
|
|
@@ -252,11 +307,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
252
307
|
return { ...out, error: `--continue must be never, deps-ok, or always` }
|
|
253
308
|
}
|
|
254
309
|
out.continueMode = v
|
|
255
|
-
} else if (a === '--verbosity') {
|
|
256
|
-
const v = before[++i]
|
|
257
|
-
if (v === undefined) return { ...out, error:
|
|
258
|
-
const n =
|
|
259
|
-
if (
|
|
310
|
+
} else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
|
|
311
|
+
const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
|
|
312
|
+
if (v === undefined) return { ...out, error: `--verbosity requires a value` }
|
|
313
|
+
const n = parseDecimalInt(v)
|
|
314
|
+
if (n === null) return { ...out, error: `invalid verbosity: ${v}` }
|
|
260
315
|
out.verbosity = n
|
|
261
316
|
} else if (a === '--dry') {
|
|
262
317
|
out.dry = 'text'
|
|
@@ -277,7 +332,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
277
332
|
} else if (a === '--profile') {
|
|
278
333
|
out.profile = 'profile.json'
|
|
279
334
|
} else if (a?.startsWith('--profile=')) {
|
|
280
|
-
|
|
335
|
+
// An optional-value flag: an empty `=` means "no value given", so it
|
|
336
|
+
// takes the same documented default as the bare form. Resolving ''
|
|
337
|
+
// against cwd would name the cwd DIRECTORY and die with EISDIR after
|
|
338
|
+
// the whole run — and `--summarize=` already degrades to its default.
|
|
339
|
+
out.profile = a.slice('--profile='.length) || 'profile.json'
|
|
281
340
|
} else if (a === '--affected') {
|
|
282
341
|
out.affected = ''
|
|
283
342
|
} else if (a?.startsWith('--affected=')) {
|
|
@@ -289,6 +348,16 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
289
348
|
const eq = raw.indexOf('=')
|
|
290
349
|
if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
|
|
291
350
|
out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
|
|
351
|
+
} else if (a === '--report-file' || a?.startsWith('--report-file=')) {
|
|
352
|
+
const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
|
|
353
|
+
if (v === undefined || v === '') return { ...out, error: `--report-file requires a path` }
|
|
354
|
+
// Same reasoning as --cache-dir: a path is an arbitrary string, so
|
|
355
|
+
// nothing about its shape rejects a swallowed flag. A path starting
|
|
356
|
+
// with `-` needs the `=` form.
|
|
357
|
+
if (a === '--report-file' && v.startsWith('-')) {
|
|
358
|
+
return { ...out, error: `--report-file requires a path, got flag: ${v}` }
|
|
359
|
+
}
|
|
360
|
+
out.reportFile = v
|
|
292
361
|
} else if (a === '--report') {
|
|
293
362
|
out.report = 'markdown'
|
|
294
363
|
} else if (a?.startsWith('--report=')) {
|
|
@@ -298,7 +367,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
298
367
|
}
|
|
299
368
|
out.report = fmt
|
|
300
369
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
301
|
-
return { ...out, error: `unknown flag: ${a}` }
|
|
370
|
+
return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}` }
|
|
302
371
|
} else if (a !== undefined) {
|
|
303
372
|
out.tasks.push(a)
|
|
304
373
|
}
|
|
@@ -351,9 +420,11 @@ export function detectFlow(
|
|
|
351
420
|
* honor the same selection semantics (cwd / `--all` / `--filter` /
|
|
352
421
|
* `--affected` / anchored positionals).
|
|
353
422
|
*
|
|
354
|
-
* Returns
|
|
355
|
-
*
|
|
356
|
-
*
|
|
423
|
+
* Returns the resolved options, an error message, or `nothingSelected` —
|
|
424
|
+
* the selection legitimately resolved to zero projects (nothing changed
|
|
425
|
+
* since the `--affected` base), which is a clean exit, not a failure. The
|
|
426
|
+
* caller prefixes messages with its subcommand name (`vx run: <msg>` /
|
|
427
|
+
* `vx watch: <msg>`).
|
|
357
428
|
*
|
|
358
429
|
* Assumes the caller has already populated `parsed.tasks` (e.g. via
|
|
359
430
|
* the interactive picker for `vx run`).
|
|
@@ -362,7 +433,7 @@ export async function resolveRunOptions(
|
|
|
362
433
|
parsed: RunArgs,
|
|
363
434
|
cwd: string,
|
|
364
435
|
tasks: readonly string[],
|
|
365
|
-
): Promise<RunOptions | { error: string }> {
|
|
436
|
+
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
366
437
|
for (const t of tasks) {
|
|
367
438
|
const idx = t.indexOf('#')
|
|
368
439
|
if (idx >= 0) {
|
|
@@ -387,13 +458,25 @@ export async function resolveRunOptions(
|
|
|
387
458
|
// Project scope applies to bare task names only. Anchored entries
|
|
388
459
|
// (pkg#task) resolve directly to their own project regardless.
|
|
389
460
|
const bareTasks = tasks.filter((t) => !t.includes('#'))
|
|
461
|
+
const anchoredTasks = tasks.filter((t) => t.includes('#'))
|
|
390
462
|
let projects: string[] | undefined
|
|
391
463
|
if (bareTasks.length === 0) {
|
|
392
464
|
projects = undefined
|
|
393
465
|
} else if (filterStrings.length > 0) {
|
|
394
466
|
const resolved = await resolveFilters(cwd, filterStrings)
|
|
395
|
-
if (resolved
|
|
396
|
-
|
|
467
|
+
if ('error' in resolved) return { error: resolved.error }
|
|
468
|
+
if ('empty' in resolved) {
|
|
469
|
+
// "Nothing changed" is a clean exit for the BARE tasks the filter
|
|
470
|
+
// scopes — but it must never cancel an explicitly anchored
|
|
471
|
+
// `pkg#task`, which resolves to its own project regardless of
|
|
472
|
+
// scope. An empty project list keeps the bare tasks unselected
|
|
473
|
+
// while the anchored ones still run.
|
|
474
|
+
if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
|
|
475
|
+
process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
|
|
476
|
+
projects = []
|
|
477
|
+
} else {
|
|
478
|
+
projects = resolved.names
|
|
479
|
+
}
|
|
397
480
|
} else if (parsed.all) {
|
|
398
481
|
projects = undefined
|
|
399
482
|
} else {
|
|
@@ -414,6 +497,7 @@ export async function resolveRunOptions(
|
|
|
414
497
|
flow: detectFlow(parsed),
|
|
415
498
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
416
499
|
...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
|
|
500
|
+
...(parsed.download !== undefined ? { download: parsed.download } : {}),
|
|
417
501
|
...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
|
|
418
502
|
forwardArgs: parsed.forwardArgs,
|
|
419
503
|
}
|
|
@@ -471,6 +555,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
471
555
|
process.stderr.write(`vx run: ${resolved.error}\n`)
|
|
472
556
|
return 1
|
|
473
557
|
}
|
|
558
|
+
if ('nothingSelected' in resolved) {
|
|
559
|
+
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
560
|
+
return 0
|
|
561
|
+
}
|
|
474
562
|
const opts = resolved
|
|
475
563
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
476
564
|
// show what was actually run. `args` is everything after `run`.
|
|
@@ -480,6 +568,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
480
568
|
// graph + probe the cache; the difference is just the formatter.
|
|
481
569
|
if (parsed.dry !== undefined || parsed.graph !== undefined) {
|
|
482
570
|
const plan = await planRun(opts)
|
|
571
|
+
if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.\n`,
|
|
574
|
+
)
|
|
575
|
+
return 1
|
|
576
|
+
}
|
|
483
577
|
if (plan.tasks.length === 0) {
|
|
484
578
|
process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
|
|
485
579
|
return 1
|
|
@@ -499,35 +593,93 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
499
593
|
return 0
|
|
500
594
|
}
|
|
501
595
|
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
const workspaceConfig = await loadWorkspaceConfig(root)
|
|
509
|
-
const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
|
|
510
|
-
const backend = await resolvePluginBackend(
|
|
511
|
-
plugins,
|
|
512
|
-
{
|
|
513
|
-
workspaceRoot: root,
|
|
514
|
-
cacheDir: resolveCacheDir(root, workspaceConfig),
|
|
515
|
-
warn: (m) => process.stderr.write(`${m}\n`),
|
|
516
|
-
request,
|
|
517
|
-
},
|
|
518
|
-
() => Promise.resolve(localBackend()),
|
|
519
|
-
)
|
|
520
|
-
const result = await backend.run(request)
|
|
596
|
+
// A run executes in THIS process, always. Where an individual task's
|
|
597
|
+
// command runs is the `executor` capability's business (per task, with
|
|
598
|
+
// the scheduler, cache, retries and telemetry unchanged above it); there
|
|
599
|
+
// is no whole-run delegation seam to consult.
|
|
600
|
+
const summary = await runOrchestrator(opts)
|
|
601
|
+
const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
|
|
521
602
|
if (parsed.verbosity > 0) printSummary(result)
|
|
522
|
-
// Report generation is post-run, gated on the
|
|
523
|
-
// absent.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
603
|
+
// Report generation is post-run, gated on the flags — zero cost when
|
|
604
|
+
// both are absent. Rendered once, however many sinks asked for it.
|
|
605
|
+
if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
|
|
606
|
+
const md = formatRunReportMarkdown(result)
|
|
607
|
+
// `--report` writes stdout. The report ITSELF is machine-clean, but
|
|
608
|
+
// stdout is not vx's alone — the status logger writes there too, so
|
|
609
|
+
// `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
|
|
610
|
+
// meter bar and `::group::` command above the table. `--report-file`
|
|
611
|
+
// is the redirect-free form.
|
|
612
|
+
if (parsed.report === 'markdown') process.stdout.write(md)
|
|
613
|
+
if (parsed.reportFile !== undefined) {
|
|
614
|
+
const target = path.resolve(cwd, parsed.reportFile)
|
|
615
|
+
try {
|
|
616
|
+
// APPEND, not overwrite: the flag exists for `$GITHUB_STEP_SUMMARY`,
|
|
617
|
+
// which GitHub documents as append-only and which other steps in the
|
|
618
|
+
// same job also write to. Truncating it would silently destroy their
|
|
619
|
+
// content, and would make replacing the documented `>>` recipe a
|
|
620
|
+
// behaviour change rather than a drop-in.
|
|
621
|
+
await appendFile(target, md)
|
|
622
|
+
} catch (err) {
|
|
623
|
+
// Same contract as --summarize / --profile: the run already
|
|
624
|
+
// happened, so a write failure is reported, not fatal.
|
|
625
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
626
|
+
process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
527
629
|
}
|
|
528
630
|
return result.ok ? 0 : 1
|
|
529
631
|
}
|
|
530
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
|
|
635
|
+
* `orphans` (workspace-relative paths belonging to no project).
|
|
636
|
+
*
|
|
637
|
+
* Reached ONLY when a changed path belongs to no project, so a run whose every
|
|
638
|
+
* change is inside a project never gets here — the scoped-config-loading win
|
|
639
|
+
* is untouched for the common case.
|
|
640
|
+
*
|
|
641
|
+
* `vx-lock.json` FIRST when present: it holds the resolved configs, so CI —
|
|
642
|
+
* where `--affected` matters most and the lock is committed — answers with
|
|
643
|
+
* zero evaluation. Without a lock this evaluates configs, which is the honest
|
|
644
|
+
* cost of the question: nothing cheaper can know which globs a program
|
|
645
|
+
* declares. A config that fails to load is skipped rather than failing the
|
|
646
|
+
* run, matching the deliberate Turbo-like rule that a broken out-of-scope
|
|
647
|
+
* config does not fail a scoped run.
|
|
648
|
+
*/
|
|
649
|
+
async function workspaceGlobOwners(
|
|
650
|
+
root: string,
|
|
651
|
+
projects: readonly ProjectMeta[],
|
|
652
|
+
orphans: readonly string[],
|
|
653
|
+
): Promise<string[]> {
|
|
654
|
+
const lock = await readLockfile(root)
|
|
655
|
+
const owners: string[] = []
|
|
656
|
+
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
657
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
658
|
+
const globs = task.cache?.inputs?.workspaceFiles
|
|
659
|
+
if (globs === undefined) continue
|
|
660
|
+
if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
|
|
661
|
+
}
|
|
662
|
+
return false
|
|
663
|
+
}
|
|
664
|
+
await Promise.all(
|
|
665
|
+
projects.map(async (meta) => {
|
|
666
|
+
const locked = lock?.projects[meta.name]
|
|
667
|
+
if (locked !== undefined) {
|
|
668
|
+
if (declaresMatch(locked.config)) owners.push(meta.name)
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
if (meta.configPath === null || meta.configPath === '') return
|
|
672
|
+
try {
|
|
673
|
+
if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
|
|
674
|
+
} catch {
|
|
675
|
+
// A config that will not load cannot be shown to declare a matching
|
|
676
|
+
// glob; surfacing it here would fail a run the loader itself tolerates.
|
|
677
|
+
}
|
|
678
|
+
}),
|
|
679
|
+
)
|
|
680
|
+
return owners
|
|
681
|
+
}
|
|
682
|
+
|
|
531
683
|
async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
|
|
532
684
|
const root = await findWorkspaceRoot(cwd)
|
|
533
685
|
const ws = await loadWorkspace(root)
|
|
@@ -546,10 +698,9 @@ async function findCwdProject(cwd: string): Promise<string | null> {
|
|
|
546
698
|
return best?.name ?? null
|
|
547
699
|
}
|
|
548
700
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
): Promise<{ names?: string[]; error?: string }> {
|
|
701
|
+
type FilterResolution = { names: string[] } | { error: string } | { empty: string }
|
|
702
|
+
|
|
703
|
+
async function resolveFilters(cwd: string, raw: string[]): Promise<FilterResolution> {
|
|
553
704
|
const root = await findWorkspaceRoot(cwd)
|
|
554
705
|
const projects = await loadWorkspaceProjects(cwd)
|
|
555
706
|
const graph = buildPackageGraph(projects)
|
|
@@ -562,7 +713,12 @@ async function resolveFilters(
|
|
|
562
713
|
for (const f of parsed) {
|
|
563
714
|
if (f.gitSince === undefined) continue
|
|
564
715
|
try {
|
|
565
|
-
const names = await affectedProjects({
|
|
716
|
+
const names = await affectedProjects({
|
|
717
|
+
workspaceRoot: root,
|
|
718
|
+
since: f.gitSince,
|
|
719
|
+
projects,
|
|
720
|
+
workspaceGlobOwners: (orphans) => workspaceGlobOwners(root, projects, orphans),
|
|
721
|
+
})
|
|
566
722
|
affectedByFilter.set(f, names)
|
|
567
723
|
} catch (err) {
|
|
568
724
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -570,8 +726,29 @@ async function resolveFilters(
|
|
|
570
726
|
}
|
|
571
727
|
}
|
|
572
728
|
|
|
573
|
-
const selected = applyFilters({
|
|
729
|
+
const selected = applyFilters({
|
|
730
|
+
filters: parsed,
|
|
731
|
+
projects,
|
|
732
|
+
graph,
|
|
733
|
+
affectedByFilter,
|
|
734
|
+
// A `[<since>]` selector matching nothing is the ordinary "nothing
|
|
735
|
+
// changed" outcome, reported below; only a name/path pattern that
|
|
736
|
+
// matched nothing is worth flagging as a probable typo.
|
|
737
|
+
onNoMatch: (f) => {
|
|
738
|
+
if (f.gitSince === undefined) {
|
|
739
|
+
process.stderr.write(`vx: filter "${f.raw}" matched no projects\n`)
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
})
|
|
574
743
|
if (selected.size === 0) {
|
|
744
|
+
// "Nothing changed" is a legitimate outcome, not a typo — a docs-only
|
|
745
|
+
// commit must not red `vx run … --affected=origin/main`. Only report an
|
|
746
|
+
// error when the user named something concrete that failed to resolve.
|
|
747
|
+
const includes = parsed.filter((f) => !f.negate)
|
|
748
|
+
if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
|
|
749
|
+
const refs = includes.map((f) => f.gitSince).join(', ')
|
|
750
|
+
return { empty: `nothing affected since ${refs}` }
|
|
751
|
+
}
|
|
575
752
|
return { error: `no projects matched filter(s): ${raw.join(', ')}` }
|
|
576
753
|
}
|
|
577
754
|
return { names: [...selected].sort() }
|
|
@@ -681,3 +858,18 @@ function formatRow(o: OutcomeView): { task: string; status: string; duration: st
|
|
|
681
858
|
duration: `${o.durationMs}ms`,
|
|
682
859
|
}
|
|
683
860
|
}
|
|
861
|
+
|
|
862
|
+
/** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
|
|
863
|
+
function didYouMeanFlag(flag: string): string {
|
|
864
|
+
const name = flag.replace(/=.*$/, '')
|
|
865
|
+
let best: string | undefined
|
|
866
|
+
let bestD = 3
|
|
867
|
+
for (const f of documentedFlags('run')) {
|
|
868
|
+
const d = editDistance(name, f)
|
|
869
|
+
if (d < bestD) {
|
|
870
|
+
bestD = d
|
|
871
|
+
best = f
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return best === undefined || best === name ? '' : ` (did you mean ${best}?)`
|
|
875
|
+
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `vx upgrade [tag]` — self-update the compiled binary in place.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Downloads the GitHub release asset for this os/arch, writes next to
|
|
3
|
+
// the current executable, atomic rename over it.
|
|
4
4
|
// Named `upgrade` (not `update`) per CLI convention: bun upgrade,
|
|
5
5
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ export function isBunfsPath(p: string): boolean {
|
|
|
24
24
|
* `Bun.main` (and argv[1]) rather than `import.meta.path`: with
|
|
25
25
|
* `--minify --bytecode` — vx's release build flags — `import.meta.path`
|
|
26
26
|
* reports the ORIGINAL SOURCE path, not the bunfs path, so the old
|
|
27
|
-
* check silently failed for every
|
|
27
|
+
* check silently failed for every installed binary and `vx
|
|
28
28
|
* upgrade` refused with "running from source". `Bun.main` stays the
|
|
29
29
|
* bunfs path under every compile-flag combination.
|
|
30
30
|
*/
|
|
@@ -68,7 +68,7 @@ export async function replaceBinary(dest: string, url: string): Promise<void> {
|
|
|
68
68
|
const msg = err instanceof Error ? err.message : String(err)
|
|
69
69
|
throw new UserError(
|
|
70
70
|
`vx upgrade: could not replace ${dest} (${msg}) — ` +
|
|
71
|
-
`check permissions, or reinstall
|
|
71
|
+
`check permissions, or reinstall with npm install -g @vzn/vx`,
|
|
72
72
|
)
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -82,8 +82,9 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
82
82
|
}
|
|
83
83
|
if (!isCompiledBinary()) {
|
|
84
84
|
throw new UserError(
|
|
85
|
-
'vx upgrade only works for the compiled binary
|
|
86
|
-
'You are running from source — use git pull instead.'
|
|
85
|
+
'vx upgrade only works for the compiled binary. ' +
|
|
86
|
+
'You are running from source — use git pull instead. ' +
|
|
87
|
+
'(npm installs update with: npm install -g @vzn/vx@latest)',
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
const asset = assetName()
|