@vzn/vx 0.0.594 → 0.0.596
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 +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
package/src/cache/zstd.ts
CHANGED
|
@@ -194,15 +194,21 @@ export async function decodedTar(
|
|
|
194
194
|
}
|
|
195
195
|
return oneChunk(await zstdDecompressBounded(source, hash, cap))
|
|
196
196
|
}
|
|
197
|
+
// A BunFile reads no further than the size its first stat cached, and a
|
|
198
|
+
// save or ingest of the same key may rename a longer copy over the path
|
|
199
|
+
// after that stat: the read came back cut short, the restore called the
|
|
200
|
+
// new artifact corrupt and dropped it. The size only picks the decode;
|
|
201
|
+
// each read goes through a handle that has not stat the path yet.
|
|
202
|
+
const fresh = (): Bun.BunFile => Bun.file(source.name!)
|
|
197
203
|
if (source.size <= STREAM_DECODE_FROM) {
|
|
198
|
-
const bytes = await
|
|
204
|
+
const bytes = await fresh().bytes()
|
|
199
205
|
if (assertDeclaredSize(bytes, hash, cap) === null || !isOneFrame(bytes)) {
|
|
200
|
-
return zstdDecodeStream(
|
|
206
|
+
return zstdDecodeStream(new Blob([bytes]), hash, cap)
|
|
201
207
|
}
|
|
202
208
|
return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
|
|
203
209
|
}
|
|
204
|
-
assertDeclaredSize(await
|
|
205
|
-
return zstdDecodeStream(
|
|
210
|
+
assertDeclaredSize(await fresh().slice(0, 32).bytes(), hash, cap)
|
|
211
|
+
return zstdDecodeStream(fresh(), hash, cap)
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/**
|
package/src/cli/cache.ts
CHANGED
|
@@ -85,10 +85,15 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
85
85
|
if (a === '--older-than' || a?.startsWith('--older-than=')) {
|
|
86
86
|
const v = a === '--older-than' ? args[++i] : a.slice('--older-than='.length)
|
|
87
87
|
if (v === undefined || v === '') {
|
|
88
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
error: `--older-than requires a value: a duration like 30d, 24h or 60m${seeHelp('cache')}`,
|
|
90
|
+
}
|
|
89
91
|
}
|
|
90
92
|
const ms = parseDuration(v)
|
|
91
|
-
if (ms === null)
|
|
93
|
+
if (ms === null)
|
|
94
|
+
return {
|
|
95
|
+
error: `--older-than must be a duration like 30d, 24h or 60m (got ${v})${seeHelp('cache')}`,
|
|
96
|
+
}
|
|
92
97
|
// `0d` evicts everything ever cached. That is far more often a
|
|
93
98
|
// computed-to-zero retention than an intent, and no other flag
|
|
94
99
|
// combination expresses "wipe the cache" — so refuse it and name
|
|
@@ -102,10 +107,11 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
102
107
|
} else if (a === '--max-size' || a?.startsWith('--max-size=')) {
|
|
103
108
|
const v = a === '--max-size' ? args[++i] : a.slice('--max-size='.length)
|
|
104
109
|
if (v === undefined || v === '') {
|
|
105
|
-
return { error: `--max-size requires a value
|
|
110
|
+
return { error: `--max-size requires a value: a size like 500M or 1G${seeHelp('cache')}` }
|
|
106
111
|
}
|
|
107
112
|
const bytes = parseSize(v)
|
|
108
|
-
if (bytes === null)
|
|
113
|
+
if (bytes === null)
|
|
114
|
+
return { error: `--max-size must be a size like 500M or 1G (got ${v})${seeHelp('cache')}` }
|
|
109
115
|
if (bytes === 0) {
|
|
110
116
|
return {
|
|
111
117
|
error: `--max-size 0 would evict every entry — to clear the cache, delete the cache store \`vx info\` names`,
|
|
@@ -185,19 +191,9 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
185
191
|
printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
|
|
186
192
|
return 0
|
|
187
193
|
}
|
|
188
|
-
if (parsed.dryRun === true) {
|
|
189
|
-
const earlier = await Cache.orphansBeforeReset(dir)
|
|
190
|
-
if (earlier !== null) {
|
|
191
|
-
warnToStderr(
|
|
192
|
-
`[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
|
|
193
|
-
)
|
|
194
|
-
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
|
|
195
|
-
return 0
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
194
|
const cache =
|
|
199
195
|
parsed.dryRun === true
|
|
200
|
-
? new Cache(dir, undefined, undefined, undefined, '
|
|
196
|
+
? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
|
|
201
197
|
: new Cache(dir, undefined, root, undefined, 'open', storeRoot)
|
|
202
198
|
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
203
199
|
// refused up front with the directory named, as a run refuses it, rather
|
package/src/cli/completions.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
|
|
9
9
|
import { nearest } from '../util/index.js'
|
|
10
10
|
|
|
11
|
+
/** A flag whose value is one of a fixed set; the tests hold each to its parser. */
|
|
12
|
+
export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
|
|
13
|
+
'--format': ['pretty', 'json'],
|
|
14
|
+
'--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
|
|
15
|
+
'--download': ['all', 'toplevel', 'none'],
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export type CompletionShell = 'bash' | 'zsh' | 'fish'
|
|
12
19
|
const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
|
|
13
20
|
|
|
@@ -36,6 +43,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
36
43
|
...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
|
|
37
44
|
],
|
|
38
45
|
}))
|
|
46
|
+
// `vx run --output-logs <Tab>` offered the flags again; a flag with a
|
|
47
|
+
// fixed value set offers that set.
|
|
48
|
+
const values = table.flatMap((t) =>
|
|
49
|
+
t.words.flatMap((w) => {
|
|
50
|
+
const vs = FLAG_VALUES[w]
|
|
51
|
+
return vs === undefined ? [] : [{ verb: t.verb, flag: w, values: vs.join(' ') }]
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
39
54
|
switch (shell) {
|
|
40
55
|
case 'bash':
|
|
41
56
|
return [
|
|
@@ -43,10 +58,23 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
43
58
|
'_vx() {',
|
|
44
59
|
' local cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
45
60
|
' local verb="${COMP_WORDS[1]}"',
|
|
61
|
+
' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
|
|
46
62
|
' if [ "$COMP_CWORD" -eq 1 ]; then',
|
|
47
63
|
` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
|
|
48
64
|
' return',
|
|
49
65
|
' fi',
|
|
66
|
+
// bash splits `--format=js` at the `=` (COMP_WORDBREAKS).
|
|
67
|
+
' if [ "$cur" = "=" ]; then',
|
|
68
|
+
' cur=""',
|
|
69
|
+
' elif [ "$prev" = "=" ]; then',
|
|
70
|
+
' prev="${COMP_WORDS[COMP_CWORD-2]}"',
|
|
71
|
+
' fi',
|
|
72
|
+
' case "$verb $prev" in',
|
|
73
|
+
...values.map(
|
|
74
|
+
(v) =>
|
|
75
|
+
` "${v.verb} ${v.flag}") COMPREPLY=($(compgen -W "${v.values}" -- "$cur")); return ;;`,
|
|
76
|
+
),
|
|
77
|
+
' esac',
|
|
50
78
|
' case "$verb" in',
|
|
51
79
|
...table.map(
|
|
52
80
|
(t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
|
|
@@ -66,6 +94,9 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
66
94
|
` compadd -- ${verbs.join(' ')}`,
|
|
67
95
|
' return',
|
|
68
96
|
' fi',
|
|
97
|
+
' case "${words[2]} ${words[CURRENT-1]}" in',
|
|
98
|
+
...values.map((v) => ` "${v.verb} ${v.flag}") compadd -- ${v.values}; return ;;`),
|
|
99
|
+
' esac',
|
|
69
100
|
' case "${words[2]}" in',
|
|
70
101
|
...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
|
|
71
102
|
' esac',
|
|
@@ -86,11 +117,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
86
117
|
'complete -c vx -f',
|
|
87
118
|
...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
|
|
88
119
|
...table.flatMap((t) =>
|
|
89
|
-
t.words.map((w) =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
120
|
+
t.words.map((w) => {
|
|
121
|
+
const on = `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}"`
|
|
122
|
+
if (!w.startsWith('--')) return `${on} -a ${w}`
|
|
123
|
+
const vs = FLAG_VALUES[w]
|
|
124
|
+
return vs === undefined
|
|
125
|
+
? `${on} -l ${w.slice(2)}`
|
|
126
|
+
: `${on} -l ${w.slice(2)} -x -a "${vs.join(' ')}"`
|
|
127
|
+
}),
|
|
94
128
|
),
|
|
95
129
|
'',
|
|
96
130
|
].join('\n')
|
|
@@ -111,7 +145,9 @@ export async function completionsCmd(
|
|
|
111
145
|
return 1
|
|
112
146
|
}
|
|
113
147
|
const one = shell !== undefined && args.length === 1
|
|
114
|
-
|
|
148
|
+
// One edit: shell names are three or four letters, so two edits took
|
|
149
|
+
// `tcsh` to `bash`, a different shell.
|
|
150
|
+
const best = one ? nearest(shell, SHELLS, 1) : undefined
|
|
115
151
|
process.stderr.write(
|
|
116
152
|
`vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
|
|
117
153
|
)
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -36,6 +36,17 @@ const list = (v: string | undefined): string[] =>
|
|
|
36
36
|
.map((s) => s.trim())
|
|
37
37
|
.filter((s) => s !== '')
|
|
38
38
|
|
|
39
|
+
// Nx's `--projects` labels (find-matching-projects, 23.2.1): `name:` and
|
|
40
|
+
// `directory:` say which match to make; vx's filter says it by form. `tag:`
|
|
41
|
+
// is a filter form already.
|
|
42
|
+
const nxPattern = (p: string): string => {
|
|
43
|
+
const neg = p.startsWith('!') ? '!' : ''
|
|
44
|
+
const s = neg === '' ? p : p.slice(1)
|
|
45
|
+
if (s.startsWith('name:')) return `${neg}${s.slice(5)}`
|
|
46
|
+
if (s.startsWith('directory:')) return `${neg}./${s.slice(10)}`
|
|
47
|
+
return p
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
|
|
40
51
|
|
|
41
52
|
export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
@@ -142,7 +153,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
142
153
|
outcome: 'same',
|
|
143
154
|
vx: '`--output-logs full|errors-only|hash-only|none`',
|
|
144
155
|
},
|
|
145
|
-
{
|
|
156
|
+
{
|
|
157
|
+
runner: 'turbo',
|
|
158
|
+
names: ['--no-cache'],
|
|
159
|
+
value: false,
|
|
160
|
+
outcome: 'same',
|
|
161
|
+
// Turbo 2.11's own help: "Equivalent to `--cache=local:r,remote:r`".
|
|
162
|
+
vx: "`--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r`",
|
|
163
|
+
},
|
|
146
164
|
{
|
|
147
165
|
runner: 'turbo',
|
|
148
166
|
names: ['--cache'],
|
|
@@ -357,7 +375,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
357
375
|
names: ['--preflight'],
|
|
358
376
|
value: false,
|
|
359
377
|
outcome: 'refuse',
|
|
360
|
-
vx: '`turboCache(
|
|
378
|
+
vx: 'set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1`',
|
|
361
379
|
},
|
|
362
380
|
{
|
|
363
381
|
runner: 'turbo',
|
|
@@ -407,12 +425,15 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
407
425
|
names: ['-p', '--projects'],
|
|
408
426
|
value: true,
|
|
409
427
|
outcome: 'alias',
|
|
410
|
-
vx: "`--filter <pattern>`, one per project; a list opening with `!` starts from all (`--filter '*'`)",
|
|
428
|
+
vx: "`--filter <pattern>`, one per project (`directory:<d>` is `./<d>`, `name:<n>` is `<n>`); a list opening with `!` starts from all (`--filter '*'`)",
|
|
411
429
|
// Nx reads a leading exclusion as "all projects but": `-p '!b,a'` is
|
|
412
430
|
// every project except b, where vx's filters alone would select a.
|
|
413
431
|
to: (v) => {
|
|
414
432
|
const ps = list(v)
|
|
415
|
-
return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
|
|
433
|
+
return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
|
|
434
|
+
'--filter',
|
|
435
|
+
nxPattern(p),
|
|
436
|
+
])
|
|
416
437
|
},
|
|
417
438
|
},
|
|
418
439
|
{
|
|
@@ -421,7 +442,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
421
442
|
value: true,
|
|
422
443
|
outcome: 'alias',
|
|
423
444
|
vx: "`--filter '!<pattern>'`, one per project",
|
|
424
|
-
to: (v) => list(v).flatMap((p) => ['--filter', `!${p}`]),
|
|
445
|
+
to: (v) => list(v).flatMap((p) => ['--filter', `!${nxPattern(p)}`]),
|
|
425
446
|
},
|
|
426
447
|
{
|
|
427
448
|
runner: 'nx',
|
package/src/cli/help.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
111
111
|
' vx lock [--check]',
|
|
112
112
|
' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
|
|
113
113
|
' vx upgrade [TAG]',
|
|
114
|
-
' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
|
|
114
|
+
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
115
115
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
116
116
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
117
117
|
' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
|
|
@@ -140,8 +140,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
140
140
|
' to its flags; unnamed layers keep their value. Examples:',
|
|
141
141
|
' local:rw,remote:r (remote read-only), remote: (remote off),',
|
|
142
142
|
' local:r (local read-only).',
|
|
143
|
-
' --cache-dir <path> Cache directory override (cwd-relative). Beats
|
|
144
|
-
' cacheDir field
|
|
143
|
+
' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
|
|
144
|
+
' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
|
|
145
145
|
' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
|
|
146
146
|
" without their own exec.retries; explicit config wins). Doesn't",
|
|
147
147
|
' affect cache keys.',
|
|
@@ -209,6 +209,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
209
209
|
' evaluation, not the lock), one block per task.',
|
|
210
210
|
" vx show <pkg>#<task> Print a single task's resolved config.",
|
|
211
211
|
' vx show <task> That task in every project declaring it.',
|
|
212
|
+
" --filter <pattern> The list (or a task's projects) as `vx run --filter` selects.",
|
|
213
|
+
' --affected[=<ref>] Only what `vx run --affected` selects: changed and dependents.',
|
|
212
214
|
' --format <fmt> pretty (default) | json.',
|
|
213
215
|
' vx info Workspace doctor: vx/bun/git versions, project +',
|
|
214
216
|
' task counts, cache dir/entries/size, recent runs,',
|
package/src/cli/init.ts
CHANGED
|
@@ -369,7 +369,7 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
|
|
|
369
369
|
const withScripts = dirs
|
|
370
370
|
.filter((dir) => {
|
|
371
371
|
const scripts = (
|
|
372
|
-
JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as {
|
|
372
|
+
JSON.parse(readText(path.join(dir, 'package.json')).replace(/^\uFEFF/, '') || '{}') as {
|
|
373
373
|
scripts?: unknown
|
|
374
374
|
}
|
|
375
375
|
).scripts
|
package/src/cli/last.ts
CHANGED
|
@@ -60,7 +60,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
60
60
|
if (spaced) i++
|
|
61
61
|
const n = Number(lv)
|
|
62
62
|
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
63
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
...out,
|
|
65
|
+
error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
out.list = n
|
|
66
69
|
continue
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
29
29
|
let miss = 0
|
|
30
30
|
let nocache = 0
|
|
31
31
|
let noop = 0
|
|
32
|
+
let idle = 0
|
|
32
33
|
for (const t of real) {
|
|
33
34
|
// `@noop`: the run succeeds it without running, whatever its key says.
|
|
34
35
|
const skipped = t.executor === 'noop'
|
|
@@ -56,6 +57,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
56
57
|
else if (t.cacheStatus === 'hit-remote') remote++
|
|
57
58
|
else if (t.cacheStatus === 'miss') miss++
|
|
58
59
|
else if (t.cacheStatus === 'no-cache') nocache++
|
|
60
|
+
else if (t.cacheStatus === 'not-started') idle++
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const hitParts: string[] = []
|
|
@@ -67,6 +69,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
67
69
|
if (miss > 0) summary.push(`${miss} would run`)
|
|
68
70
|
if (nocache > 0) summary.push(`${nocache} no-cache`)
|
|
69
71
|
if (noop > 0) summary.push(`${noop} noop`)
|
|
72
|
+
if (idle > 0) summary.push(`${idle} not started`)
|
|
70
73
|
lines.push('')
|
|
71
74
|
lines.push(summary.join(', ') + '.')
|
|
72
75
|
|
|
@@ -199,6 +202,8 @@ function symbolFor(s: CacheStatus): string {
|
|
|
199
202
|
return '·'
|
|
200
203
|
case 'group':
|
|
201
204
|
return '○'
|
|
205
|
+
case 'not-started':
|
|
206
|
+
return '−'
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
@@ -214,6 +219,8 @@ function describe(s: CacheStatus): string {
|
|
|
214
219
|
return 'no-cache (would exec)'
|
|
215
220
|
case 'group':
|
|
216
221
|
return 'group task'
|
|
222
|
+
case 'not-started':
|
|
223
|
+
return 'not started (unneeded)'
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
|
|
@@ -229,5 +236,7 @@ function dotColor(s: CacheStatus): string {
|
|
|
229
236
|
return '#e5e7eb' // gray-200
|
|
230
237
|
case 'group':
|
|
231
238
|
return '#f5d0fe' // fuchsia-200
|
|
239
|
+
case 'not-started':
|
|
240
|
+
return '#ffffff'
|
|
232
241
|
}
|
|
233
242
|
}
|
|
@@ -20,8 +20,8 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
20
20
|
},
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
|
23
|
-
"// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n const child = Bun.spawn([sh, '-c',
|
|
24
|
-
test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n",
|
|
23
|
+
"// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
|
|
24
|
+
test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
|
|
25
25
|
},
|
|
26
26
|
graph: {
|
|
27
27
|
plugin:
|
package/src/cli/run.ts
CHANGED
|
@@ -3,7 +3,6 @@ import path from 'node:path'
|
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
4
|
import { translateForeign } from './foreign-flags.js'
|
|
5
5
|
import { flagHint, seeHelp } from './help.js'
|
|
6
|
-
import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
|
|
7
6
|
import {
|
|
8
7
|
planRun,
|
|
9
8
|
formatRunReportMarkdown,
|
|
@@ -13,12 +12,11 @@ import {
|
|
|
13
12
|
type RunOptions,
|
|
14
13
|
type RunResult,
|
|
15
14
|
} from '../orchestrator/index.js'
|
|
16
|
-
import type { ContinueMode } from '../graph/index.js'
|
|
15
|
+
import type { ContinueMode, TaskOutcome } from '../graph/index.js'
|
|
17
16
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
18
|
-
import { findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
17
|
+
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
19
18
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
20
|
-
import {
|
|
21
|
-
import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
19
|
+
import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
22
20
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
23
21
|
|
|
24
22
|
export interface RunArgs {
|
|
@@ -113,7 +111,7 @@ export function parseConcurrency(v: string, cpus = machineParallelism()): number
|
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
/** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
|
|
116
|
-
* its
|
|
114
|
+
* its flag hints named `vx run` (M-58). The caller appends the help pointer. */
|
|
117
115
|
export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
|
|
118
116
|
const out: RunArgs = {
|
|
119
117
|
tasks: [],
|
|
@@ -185,7 +183,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
185
183
|
} else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
|
|
186
184
|
return {
|
|
187
185
|
...out,
|
|
188
|
-
error: `unknown flag: ${a} (the flag is --exclude-dependencies)
|
|
186
|
+
error: `unknown flag: ${a} (the flag is --exclude-dependencies)`,
|
|
189
187
|
}
|
|
190
188
|
} else if (a === '--exclude-dependencies') {
|
|
191
189
|
out.excludeDependencies = 'all'
|
|
@@ -215,7 +213,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
215
213
|
return { ...out, error: `--retry requires a value (a non-negative integer)` }
|
|
216
214
|
const n = parseDecimalInt(v)
|
|
217
215
|
if (n === null) {
|
|
218
|
-
return { ...out, error: `--retry must be a non-negative integer
|
|
216
|
+
return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
|
|
219
217
|
}
|
|
220
218
|
out.retries = n
|
|
221
219
|
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
@@ -234,19 +232,31 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
234
232
|
}
|
|
235
233
|
}
|
|
236
234
|
if (n === null || n <= 0) {
|
|
237
|
-
return { ...out, error: `--timeout must be a positive integer
|
|
235
|
+
return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
|
|
238
236
|
}
|
|
239
237
|
out.timeout = n
|
|
240
238
|
} else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
|
|
241
239
|
const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
|
|
240
|
+
if (v === undefined || v === '') {
|
|
241
|
+
return {
|
|
242
|
+
...out,
|
|
243
|
+
error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
242
246
|
if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
|
|
243
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
...out,
|
|
249
|
+
error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
|
|
250
|
+
}
|
|
244
251
|
}
|
|
245
252
|
out.outputLogs = v
|
|
246
253
|
} else if (a === '--download' || a?.startsWith('--download=')) {
|
|
247
254
|
const v = a === '--download' ? before[++i] : a.slice('--download='.length)
|
|
255
|
+
if (v === undefined || v === '') {
|
|
256
|
+
return { ...out, error: `--download requires a value (all, toplevel, or none)` }
|
|
257
|
+
}
|
|
248
258
|
if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
|
|
249
|
-
return { ...out, error: `--download must be all, toplevel, or none` }
|
|
259
|
+
return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
|
|
250
260
|
}
|
|
251
261
|
out.download = v
|
|
252
262
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
@@ -301,8 +311,11 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
301
311
|
out.continueMode = 'always'
|
|
302
312
|
} else if (a?.startsWith('--continue=')) {
|
|
303
313
|
const v = a.slice('--continue='.length)
|
|
314
|
+
if (v === '') {
|
|
315
|
+
return { ...out, error: `--continue= requires a mode (never, deps-ok, or always)` }
|
|
316
|
+
}
|
|
304
317
|
if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
|
|
305
|
-
return { ...out, error: `--continue must be never, deps-ok, or always` }
|
|
318
|
+
return { ...out, error: `--continue must be never, deps-ok, or always (got ${v})` }
|
|
306
319
|
}
|
|
307
320
|
out.continueMode = v
|
|
308
321
|
} else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
|
|
@@ -366,7 +379,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
366
379
|
}
|
|
367
380
|
out.report = fmt
|
|
368
381
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
369
|
-
return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}
|
|
382
|
+
return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
|
|
370
383
|
} else if (a !== undefined) {
|
|
371
384
|
out.tasks.push(a)
|
|
372
385
|
}
|
|
@@ -446,20 +459,9 @@ async function scopeFilters(
|
|
|
446
459
|
const filterStrings = [...parsed.filters]
|
|
447
460
|
let affectedFilter: string | undefined
|
|
448
461
|
if (parsed.affected !== undefined) {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// nx.json's `defaultBase` or `TURBO_SCM_BASE` — comes before the guess.
|
|
453
|
-
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
454
|
-
if (base === '') {
|
|
455
|
-
try {
|
|
456
|
-
base = await defaultAffectedBase(root)
|
|
457
|
-
} catch (err) {
|
|
458
|
-
if (!isUserError(err)) throw err
|
|
459
|
-
return { error: err.message }
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
affectedFilter = `...[${base}]`
|
|
462
|
+
const f = await affectedFilterFor(cwd, parsed.affected)
|
|
463
|
+
if (typeof f === 'object') return f
|
|
464
|
+
affectedFilter = f
|
|
463
465
|
filterStrings.unshift(affectedFilter)
|
|
464
466
|
}
|
|
465
467
|
return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
|
|
@@ -484,6 +486,7 @@ export async function resolveRunOptions(
|
|
|
484
486
|
parsed: RunArgs,
|
|
485
487
|
cwd: string,
|
|
486
488
|
tasks: readonly string[],
|
|
489
|
+
verb: 'run' | 'watch' = 'run',
|
|
487
490
|
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
488
491
|
for (const t of tasks) {
|
|
489
492
|
const idx = t.indexOf('#')
|
|
@@ -555,8 +558,7 @@ export async function resolveRunOptions(
|
|
|
555
558
|
const nx = await nxTargetHint(tasks, cwd)
|
|
556
559
|
if (nx !== null) return { error: nx }
|
|
557
560
|
return {
|
|
558
|
-
error:
|
|
559
|
-
'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
|
|
561
|
+
error: `not inside a project: run from a project directory, or pass --all or --filter <pattern>${seeHelp(verb)}`,
|
|
560
562
|
}
|
|
561
563
|
}
|
|
562
564
|
projects = [cwdProject.name]
|
|
@@ -612,7 +614,7 @@ const TASKS_SHOWN = 12
|
|
|
612
614
|
export async function runCmd(args: readonly string[]): Promise<number> {
|
|
613
615
|
const parsed = parseRunArgs(args)
|
|
614
616
|
if (parsed.error) {
|
|
615
|
-
process.stderr.write(`vx run: ${parsed.error}\n`)
|
|
617
|
+
process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
|
|
616
618
|
return 1
|
|
617
619
|
}
|
|
618
620
|
|
|
@@ -724,6 +726,25 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
724
726
|
return 0
|
|
725
727
|
}
|
|
726
728
|
|
|
729
|
+
// The report prints above the footer: nothing prints below it (owner).
|
|
730
|
+
// Rendered once, however many sinks asked for it; a kept server's crash
|
|
731
|
+
// after the footer re-renders the file's copy (C-53).
|
|
732
|
+
const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
|
|
733
|
+
let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
|
|
734
|
+
if (wantsReport) {
|
|
735
|
+
opts.beforeFooter = (outcomes, ok) => {
|
|
736
|
+
reported = {
|
|
737
|
+
outcomes,
|
|
738
|
+
md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
|
|
739
|
+
}
|
|
740
|
+
// `--report` writes stdout. The report ITSELF is machine-clean, but
|
|
741
|
+
// stdout is not vx's alone — the status logger writes there too, so
|
|
742
|
+
// `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
|
|
743
|
+
// meter bar and `::group::` command above the table. `--report-file`
|
|
744
|
+
// is the redirect-free form.
|
|
745
|
+
return parsed.report === 'markdown' ? reported.md : ''
|
|
746
|
+
}
|
|
747
|
+
}
|
|
727
748
|
// A run executes in THIS process, always. Where an individual task's
|
|
728
749
|
// command runs is the `executor` capability's business (per task, with
|
|
729
750
|
// the scheduler, cache, retries and telemetry unchanged above it); there
|
|
@@ -733,17 +754,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
733
754
|
process.stderr.write(`vx run: ${summary.refused}\n`)
|
|
734
755
|
return 1
|
|
735
756
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
// meter bar and `::group::` command above the table. `--report-file`
|
|
745
|
-
// is the redirect-free form.
|
|
746
|
-
if (parsed.report === 'markdown') process.stdout.write(md)
|
|
757
|
+
if (wantsReport) {
|
|
758
|
+
const md =
|
|
759
|
+
reported?.outcomes === summary.outcomes
|
|
760
|
+
? reported.md
|
|
761
|
+
: formatRunReportMarkdown({
|
|
762
|
+
ok: summary.ok,
|
|
763
|
+
outcomes: summary.outcomes.map(projectOutcome),
|
|
764
|
+
})
|
|
747
765
|
if (parsed.reportFile !== undefined) {
|
|
748
766
|
const target = path.resolve(cwd, parsed.reportFile)
|
|
749
767
|
try {
|
|
@@ -766,5 +784,5 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
766
784
|
}
|
|
767
785
|
}
|
|
768
786
|
}
|
|
769
|
-
return
|
|
787
|
+
return summary.ok ? 0 : 1
|
|
770
788
|
}
|