@vzn/vx 0.0.630 → 0.0.632
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 +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
package/src/cli/init.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// `vx init [--dry] [--force] [--mjs]` — a workspace from nowhere:
|
|
2
|
-
// per package from its package.json scripts, and the
|
|
3
|
-
// run needs. In a Turbo or Nx repo it
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// `vx init [--dry] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
|
|
2
|
+
// one vx.config.ts per package from its package.json scripts, and the
|
|
3
|
+
// workspace file every run needs. In a Turbo or Nx repo it hands over to
|
|
4
|
+
// `@vzn/vx-migrate` (`migrate`), which asks a terminal whether to write
|
|
5
|
+
// native config; `--keep` writes only the workspace file, declaring
|
|
6
|
+
// `turbo()` or `nx()`, which read the runner's own config live (`adopt`).
|
|
6
7
|
// `vx init --plugin <seam>` writes a runnable plugin for one seam and its
|
|
7
8
|
// test instead (plugin-templates.ts, the examples the gate runs).
|
|
8
9
|
|
|
@@ -11,6 +12,8 @@ import { mkdir, unlink } from 'node:fs/promises'
|
|
|
11
12
|
import path from 'node:path'
|
|
12
13
|
import { flagHint, seeHelp } from './help.js'
|
|
13
14
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
15
|
+
import { isCompiledBinary } from './upgrade.js'
|
|
16
|
+
import { VERSION } from '../version.js'
|
|
14
17
|
import { isUserError, relPosix, UserError } from '../util/index.js'
|
|
15
18
|
import {
|
|
16
19
|
applyMigration,
|
|
@@ -29,6 +32,8 @@ interface InitArgs {
|
|
|
29
32
|
mjs: boolean
|
|
30
33
|
/** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
|
|
31
34
|
plugin?: string
|
|
35
|
+
/** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
|
|
36
|
+
mode?: 'native' | 'keep'
|
|
32
37
|
error?: string
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -49,10 +54,22 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
49
54
|
} else if (a === '--dry') out.dry = true
|
|
50
55
|
else if (a === '--force') out.force = true
|
|
51
56
|
else if (a === '--mjs') out.mjs = true
|
|
52
|
-
else if (a
|
|
57
|
+
else if (a === '--native' || a === '--keep') {
|
|
58
|
+
const mode = a === '--native' ? 'native' : 'keep'
|
|
59
|
+
if (out.mode !== undefined && out.mode !== mode) {
|
|
60
|
+
return {
|
|
61
|
+
...out,
|
|
62
|
+
error: `--native and --keep are two answers to one question; pass one${seeHelp('init')}`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
out.mode = mode
|
|
66
|
+
} else if (a.startsWith('-'))
|
|
53
67
|
return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
|
|
54
68
|
else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
|
|
55
69
|
}
|
|
70
|
+
if (out.plugin !== undefined && out.mode !== undefined) {
|
|
71
|
+
return { ...out, error: `--${out.mode} does not combine with --plugin${seeHelp('init')}` }
|
|
72
|
+
}
|
|
56
73
|
if (out.plugin !== undefined && out.mjs) {
|
|
57
74
|
return {
|
|
58
75
|
...out,
|
|
@@ -73,7 +90,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
73
90
|
// "a workspace from nowhere" still needs a package.json to start from,
|
|
74
91
|
// and the lookup's own refusal named no next step.
|
|
75
92
|
const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
|
|
76
|
-
if (isUserError(err) && err.
|
|
93
|
+
if (isUserError(err) && err.code === 'VX_E_NO_WORKSPACE') {
|
|
77
94
|
throw new UserError(
|
|
78
95
|
'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
|
|
79
96
|
)
|
|
@@ -95,14 +112,42 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
95
112
|
break
|
|
96
113
|
}
|
|
97
114
|
}
|
|
98
|
-
// A Turbo or Nx repo already says its tasks:
|
|
99
|
-
// the
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
// A Turbo or Nx repo already says its tasks: writing a config per package
|
|
116
|
+
// from the scripts dropped every edge turbo.json declares (the
|
|
117
|
+
// first-five-minutes walk, 2026-09-28). vx-migrate maps the runner's own
|
|
118
|
+
// config, so `vx init` is the one command (M, 2026-10-08); its `--keep`
|
|
119
|
+
// comes back here for the workspace file alone.
|
|
120
|
+
const runner: ['turbo' | 'nx', string] | undefined =
|
|
121
|
+
turbo !== undefined
|
|
122
|
+
? ['turbo', turbo]
|
|
123
|
+
: (await Bun.file(path.join(root, 'nx.json')).exists())
|
|
124
|
+
? ['nx', 'nx.json']
|
|
125
|
+
: undefined
|
|
126
|
+
if (runner !== undefined) {
|
|
127
|
+
if (parsed.mode === 'keep') return adopt(root, ...runner, parsed)
|
|
128
|
+
// A second `vx init` after the native migration refused to overwrite
|
|
129
|
+
// the configs it wrote, exit 1, after a package fetch: the first-run
|
|
130
|
+
// walk (2026-10-09). Set up is an answer, with the next step.
|
|
131
|
+
const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
|
|
132
|
+
if (
|
|
133
|
+
existing !== undefined &&
|
|
134
|
+
!parsed.force &&
|
|
135
|
+
!parsed.dry &&
|
|
136
|
+
parsed.mode === undefined &&
|
|
137
|
+
metas.some((m) => m.configPath !== null) &&
|
|
138
|
+
!readFileSync(path.join(root, existing), 'utf8').includes(`${runner[0]}(`)
|
|
139
|
+
) {
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`vx is already set up here (${existing} and the projects' vx.config files); --force regenerates them from ${runner[1]}.\n\nnext: ${adoptionNext(root, ...runner, ['@vzn/vx'])}\n`,
|
|
142
|
+
)
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
return migrate(root, parsed)
|
|
146
|
+
}
|
|
147
|
+
if (parsed.mode !== undefined) {
|
|
148
|
+
throw new UserError(
|
|
149
|
+
`vx init: --${parsed.mode} answers a Turbo or Nx repo's question, and no turbo.json or nx.json is at ${relPosix(process.cwd(), root) || '.'}`,
|
|
150
|
+
)
|
|
106
151
|
}
|
|
107
152
|
const unmapped = namelessNotes(root, nameless)
|
|
108
153
|
return applyMigration({
|
|
@@ -156,7 +201,35 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
|
|
|
156
201
|
}
|
|
157
202
|
|
|
158
203
|
/**
|
|
159
|
-
* `vx init` in a Turbo or Nx repo
|
|
204
|
+
* `vx init` in a Turbo or Nx repo runs `@vzn/vx-migrate`: the installed one,
|
|
205
|
+
* else this version's through `bun x`. It asks a terminal native or keep,
|
|
206
|
+
* installs what it writes, and runs `vx init --keep` for keep.
|
|
207
|
+
*/
|
|
208
|
+
async function migrate(root: string, args: InitArgs): Promise<number> {
|
|
209
|
+
const flags = [
|
|
210
|
+
args.dry && '--dry',
|
|
211
|
+
args.force && '--force',
|
|
212
|
+
args.mjs && '--mjs',
|
|
213
|
+
args.mode === 'native' && '--native',
|
|
214
|
+
].filter((f): f is string => typeof f === 'string')
|
|
215
|
+
return Bun.spawn(migrateCommand(root, flags), {
|
|
216
|
+
cwd: root,
|
|
217
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
218
|
+
// A compiled vx is a Bun runtime only when told so.
|
|
219
|
+
env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
|
|
220
|
+
}).exited
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function migrateCommand(root: string, flags: readonly string[]): string[] {
|
|
224
|
+
const installed = path.join(root, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
|
|
225
|
+
if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
|
|
226
|
+
// A source checkout is 0.0.0, which npm never had.
|
|
227
|
+
const version = VERSION === '0.0.0' ? 'latest' : VERSION
|
|
228
|
+
return [process.execPath, 'x', `@vzn/vx-migrate@${version}`, ...flags]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `vx init --keep` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
|
|
160
233
|
* that runs the repo as it is, nothing else, and the one command that gets
|
|
161
234
|
* from here to a run.
|
|
162
235
|
*/
|
|
@@ -206,7 +279,7 @@ async function adopt(
|
|
|
206
279
|
? ''
|
|
207
280
|
: `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
|
|
208
281
|
process.stdout.write(
|
|
209
|
-
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until
|
|
282
|
+
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
|
|
210
283
|
`${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
|
|
211
284
|
)
|
|
212
285
|
return 0
|
|
@@ -320,9 +393,14 @@ const INSTALL: Record<PackageManager, string> = {
|
|
|
320
393
|
const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
|
|
321
394
|
|
|
322
395
|
/** Install what the workspace file imports, if missing, then run the repo's build. */
|
|
323
|
-
export function adoptionNext(
|
|
396
|
+
export function adoptionNext(
|
|
397
|
+
root: string,
|
|
398
|
+
runner: 'turbo' | 'nx',
|
|
399
|
+
source: string,
|
|
400
|
+
needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
|
|
401
|
+
): string {
|
|
324
402
|
const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
|
|
325
|
-
const missing =
|
|
403
|
+
const missing = needs.filter(
|
|
326
404
|
(p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
|
|
327
405
|
)
|
|
328
406
|
// A global vx (no runner started this one) runs the workspace's plugins as they are.
|
package/src/cli/last.ts
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
outcomeWord,
|
|
18
18
|
type RunSummaryRow,
|
|
19
19
|
resolveRunId,
|
|
20
|
+
runFailures,
|
|
20
21
|
shortRunId,
|
|
22
|
+
taskLog,
|
|
21
23
|
} from '../orchestrator/index.js'
|
|
22
24
|
import { formatElapsed, UserError } from '../util/index.js'
|
|
23
25
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
@@ -29,6 +31,8 @@ interface LastArgs {
|
|
|
29
31
|
/** Only runs that failed: the latest one replayed, or the list narrowed. */
|
|
30
32
|
failed?: boolean
|
|
31
33
|
format: 'pretty' | 'json'
|
|
34
|
+
/** `--log <task>`: that task's output in the run, not the run's summary. */
|
|
35
|
+
log?: string
|
|
32
36
|
/** `--cache-dir`: read the history a run with the same flag wrote. */
|
|
33
37
|
cacheDir?: string
|
|
34
38
|
error?: string
|
|
@@ -72,6 +76,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
72
76
|
out.failed = true
|
|
73
77
|
continue
|
|
74
78
|
}
|
|
79
|
+
if (a === '--log' || a.startsWith('--log=')) {
|
|
80
|
+
const v = a === '--log' ? args[++i] : a.slice('--log='.length)
|
|
81
|
+
if (v === undefined || v === '' || v.startsWith('-')) {
|
|
82
|
+
return { ...out, error: `--log requires a task id, like app#build${seeHelp('last')}` }
|
|
83
|
+
}
|
|
84
|
+
out.log = v
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
75
87
|
if (a === '--format' || a.startsWith('--format=')) {
|
|
76
88
|
const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'last')
|
|
77
89
|
if (typeof fv === 'object') return { ...out, ...fv }
|
|
@@ -102,6 +114,12 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
102
114
|
error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
|
|
103
115
|
}
|
|
104
116
|
}
|
|
117
|
+
if (out.log !== undefined && (out.list !== undefined || out.failed === true)) {
|
|
118
|
+
return {
|
|
119
|
+
...out,
|
|
120
|
+
error: `--log reads one task's output and combines only with a run id${seeHelp('last')}`,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
105
123
|
if (out.runId !== undefined && out.failed === true) {
|
|
106
124
|
return {
|
|
107
125
|
...out,
|
|
@@ -282,10 +300,11 @@ export function formatRunList(
|
|
|
282
300
|
|
|
283
301
|
export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
284
302
|
const parsed = parseLastArgs(args)
|
|
285
|
-
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}
|
|
303
|
+
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`, 'VX_E_USAGE')
|
|
286
304
|
|
|
287
305
|
const root = await findWorkspaceRoot(process.cwd())
|
|
288
|
-
const
|
|
306
|
+
const cacheDir = await cliCacheDir(root, parsed.cacheDir)
|
|
307
|
+
const cache = Cache.inspect(cacheDir)
|
|
289
308
|
try {
|
|
290
309
|
const db = cache.dbHandle()
|
|
291
310
|
// Nothing recorded at all is its own answer: `--failed` said "no
|
|
@@ -293,6 +312,30 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
293
312
|
// nothing, while `--list --failed` past green runs said there were none.
|
|
294
313
|
const noRuns = (): boolean => listInvocations(db, { limit: 1 }).length === 0
|
|
295
314
|
|
|
315
|
+
if (parsed.log !== undefined) {
|
|
316
|
+
const runId =
|
|
317
|
+
parsed.runId === undefined
|
|
318
|
+
? undefined
|
|
319
|
+
: (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
|
|
320
|
+
const log = taskLog(cacheDir, db, parsed.log, runId)
|
|
321
|
+
if (log === null) {
|
|
322
|
+
throw new UserError(
|
|
323
|
+
`vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`} (a task id is project#task)`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (parsed.format === 'json') {
|
|
327
|
+
process.stdout.write(`${JSON.stringify(log)}\n`)
|
|
328
|
+
return 0
|
|
329
|
+
}
|
|
330
|
+
if (log.source === null) {
|
|
331
|
+
process.stderr.write(
|
|
332
|
+
`vx last: ${log.taskId} ended ${log.status}; vx keeps output only for failed and cached tasks\n`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
process.stdout.write(log.output)
|
|
336
|
+
return 0
|
|
337
|
+
}
|
|
338
|
+
|
|
296
339
|
if (parsed.list !== undefined) {
|
|
297
340
|
const invocations =
|
|
298
341
|
parsed.failed === true
|
|
@@ -330,12 +373,20 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
330
373
|
: parsed.failed === true
|
|
331
374
|
? 'vx last: no recorded run failed'
|
|
332
375
|
: 'vx last: no recorded runs yet — run something first',
|
|
376
|
+
'VX_E_NO_HISTORY',
|
|
333
377
|
)
|
|
334
378
|
}
|
|
335
379
|
const detail = getRun(db, inv.runId)
|
|
336
380
|
|
|
337
381
|
if (parsed.format === 'json') {
|
|
338
|
-
|
|
382
|
+
const failures = new Map(
|
|
383
|
+
(runFailures(cacheDir, db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]),
|
|
384
|
+
)
|
|
385
|
+
const tasks = (detail?.tasks ?? []).map((t) => {
|
|
386
|
+
const f = failures.get(`${t.project}#${t.task}`)
|
|
387
|
+
return f === undefined ? t : { ...t, output: f.output, locations: f.locations }
|
|
388
|
+
})
|
|
389
|
+
process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
|
|
339
390
|
return 0
|
|
340
391
|
}
|
|
341
392
|
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
|
|
2
2
|
|
|
3
|
-
import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
3
|
+
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
4
|
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -52,6 +52,9 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
52
52
|
if (taskDesc) {
|
|
53
53
|
lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
|
|
54
54
|
}
|
|
55
|
+
if (t.affected !== undefined) {
|
|
56
|
+
lines.push(` ${' '.repeat(idWidth)} affected: ${describeReason(t.affected)}`)
|
|
57
|
+
}
|
|
55
58
|
if (skipped) noop++
|
|
56
59
|
else if (t.cacheStatus === 'hit-local') local++
|
|
57
60
|
else if (t.cacheStatus === 'hit-remote') remote++
|
|
@@ -104,6 +107,23 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
104
107
|
return lines.join('\n') + '\n'
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
/** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
|
|
111
|
+
function describeReason(r: AffectedReason): string {
|
|
112
|
+
const cause =
|
|
113
|
+
r.kind === 'named'
|
|
114
|
+
? 'named on the command line'
|
|
115
|
+
: r.kind === 'selected'
|
|
116
|
+
? 'its project is selected outright'
|
|
117
|
+
: r.kind === 'package'
|
|
118
|
+
? `a ^ edge passes changed package ${r.project}`
|
|
119
|
+
: r.kind === 'input'
|
|
120
|
+
? `${r.file} changed (an input)`
|
|
121
|
+
: r.file !== undefined
|
|
122
|
+
? `${r.file} changed (in its project)`
|
|
123
|
+
: 'its project changed as a whole'
|
|
124
|
+
return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
|
|
125
|
+
}
|
|
126
|
+
|
|
107
127
|
/** A TS config may build the description from `process.env` (L-11). */
|
|
108
128
|
function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
|
|
109
129
|
const { description, exec } = t.node.config
|
|
@@ -122,6 +142,7 @@ export interface PlanTaskJson {
|
|
|
122
142
|
executor?: string
|
|
123
143
|
download?: 'deferred'
|
|
124
144
|
description?: string
|
|
145
|
+
affected?: AffectedReason
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
export function formatPlanJson(plan: RunPlan): string {
|
|
@@ -141,6 +162,7 @@ export function formatPlanJson(plan: RunPlan): string {
|
|
|
141
162
|
...(t.executor !== undefined ? { executor: t.executor } : {}),
|
|
142
163
|
...(t.download !== undefined ? { download: t.download } : {}),
|
|
143
164
|
...(description !== undefined ? { description } : {}),
|
|
165
|
+
...(t.affected !== undefined ? { affected: t.affected } : {}),
|
|
144
166
|
}
|
|
145
167
|
}),
|
|
146
168
|
...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
|
|
@@ -99,6 +99,9 @@ export async function resolvePluginCommand(
|
|
|
99
99
|
workspaceRoot: ws.workspaceRoot,
|
|
100
100
|
cacheDir: ws.cacheDir,
|
|
101
101
|
concurrency: ws.concurrency,
|
|
102
|
+
vx: (await import('./upgrade.js')).isCompiledBinary()
|
|
103
|
+
? [process.execPath]
|
|
104
|
+
: [process.execPath, Bun.main],
|
|
102
105
|
warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
|
|
103
106
|
},
|
|
104
107
|
}
|
|
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
18
18
|
"// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
|
|
19
|
-
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {
|
|
19
|
+
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
|
|
20
20
|
},
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
package/src/cli/run.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
4
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
5
5
|
import { translateForeign } from './foreign-flags.js'
|
|
6
|
-
import { flagHint, seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
7
7
|
import {
|
|
8
8
|
planRun,
|
|
9
9
|
formatRunReportMarkdown,
|
|
@@ -69,6 +69,8 @@ export interface RunArgs {
|
|
|
69
69
|
forwardArgs: string[]
|
|
70
70
|
verbosity: number
|
|
71
71
|
dry: 'text' | 'json' | undefined
|
|
72
|
+
/** `--format json`: the run's result document on stdout, every other line on stderr. */
|
|
73
|
+
format: 'pretty' | 'json'
|
|
72
74
|
graph: string | undefined
|
|
73
75
|
summarize: string | undefined
|
|
74
76
|
profile: string | undefined
|
|
@@ -132,6 +134,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
132
134
|
forwardArgs: [],
|
|
133
135
|
verbosity: 0,
|
|
134
136
|
dry: undefined,
|
|
137
|
+
format: 'pretty',
|
|
135
138
|
graph: undefined,
|
|
136
139
|
summarize: undefined,
|
|
137
140
|
profile: undefined,
|
|
@@ -342,6 +345,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
342
345
|
return { ...out, error: `--dry must be text or json (got ${fmt})` }
|
|
343
346
|
}
|
|
344
347
|
out.dry = fmt
|
|
348
|
+
} else if (a === '--format' || a?.startsWith('--format=')) {
|
|
349
|
+
const f = formatValue(a === '--format' ? before[++i] : a.slice('--format='.length), verb)
|
|
350
|
+
if (typeof f !== 'string') return { ...out, error: f.error }
|
|
351
|
+
out.format = f
|
|
345
352
|
} else if (a === '--graph') {
|
|
346
353
|
out.graph = ''
|
|
347
354
|
} else if (a?.startsWith('--graph=')) {
|
|
@@ -429,6 +436,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
429
436
|
if (out.graph !== undefined && needsRun) {
|
|
430
437
|
return { ...out, error: `--graph skips execution; ${runOnly}` }
|
|
431
438
|
}
|
|
439
|
+
if (out.format === 'json') {
|
|
440
|
+
// Each of these prints something else on stdout, which the document owns.
|
|
441
|
+
if (out.dry !== undefined)
|
|
442
|
+
return { ...out, error: '--format json runs the tasks; --dry=json prints the plan' }
|
|
443
|
+
if (out.graph === '')
|
|
444
|
+
return {
|
|
445
|
+
...out,
|
|
446
|
+
error: '--format json and --graph both print on stdout; give --graph a file',
|
|
447
|
+
}
|
|
448
|
+
if (out.report === 'markdown')
|
|
449
|
+
return {
|
|
450
|
+
...out,
|
|
451
|
+
error: '--format json and --report=markdown both print on stdout; use --report-file',
|
|
452
|
+
}
|
|
453
|
+
}
|
|
432
454
|
return out
|
|
433
455
|
}
|
|
434
456
|
|
|
@@ -614,6 +636,7 @@ export async function resolveRunOptions(
|
|
|
614
636
|
if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
|
|
615
637
|
if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
|
|
616
638
|
if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
|
|
639
|
+
if (parsed.format === 'json') opts.json = true
|
|
617
640
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
618
641
|
if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
|
|
619
642
|
|
|
@@ -626,8 +649,7 @@ const TASKS_SHOWN = 12
|
|
|
626
649
|
export async function runCmd(args: readonly string[]): Promise<number> {
|
|
627
650
|
const parsed = parseRunArgs(args)
|
|
628
651
|
if (parsed.error) {
|
|
629
|
-
|
|
630
|
-
return 1
|
|
652
|
+
return refuse('run', args, `${parsed.error}${seeHelp('run')}`, 'VX_E_USAGE')
|
|
631
653
|
}
|
|
632
654
|
|
|
633
655
|
const cwd = process.cwd()
|
|
@@ -638,7 +660,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
638
660
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
639
661
|
// explicit anchored positional.
|
|
640
662
|
if (tasks.length === 0) {
|
|
641
|
-
|
|
663
|
+
// A JSON answer is for a program, which cannot pick from a menu: an
|
|
664
|
+
// agent on a pty waited at the picker forever.
|
|
665
|
+
const forProgram = parsed.format === 'json' || parsed.dry === 'json'
|
|
666
|
+
if (!process.stdin.isTTY || forProgram) {
|
|
642
667
|
const names = await taskNamesHere(cwd)
|
|
643
668
|
const shown = (names ?? []).slice(0, TASKS_SHOWN)
|
|
644
669
|
const more = (names?.length ?? 0) - shown.length
|
|
@@ -646,10 +671,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
646
671
|
shown.length === 0
|
|
647
672
|
? 'vx run <task>, e.g. vx run build'
|
|
648
673
|
: `tasks here: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
|
|
649
|
-
|
|
650
|
-
|
|
674
|
+
return refuse(
|
|
675
|
+
'run',
|
|
676
|
+
args,
|
|
677
|
+
`missing task name (${forProgram ? 'a JSON answer opens' : 'stdin is not a TTY, so'} no picker; ${tasksHere})${seeHelp('run')}`,
|
|
678
|
+
'VX_E_USAGE',
|
|
651
679
|
)
|
|
652
|
-
return 1
|
|
653
680
|
}
|
|
654
681
|
// Every run needs git: refused here, not after the user has chosen.
|
|
655
682
|
const refusal = gitRefusal(await findWorkspaceRoot(cwd))
|
|
@@ -664,17 +691,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
664
691
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
665
692
|
// the menu listed every project and the anchored pick ran outside it.
|
|
666
693
|
const scope = await scopeFilters(parsed, cwd)
|
|
667
|
-
if ('error' in scope)
|
|
668
|
-
process.stderr.write(`vx run: ${scope.error}\n`)
|
|
669
|
-
return 1
|
|
670
|
-
}
|
|
694
|
+
if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
|
|
671
695
|
let only: ReadonlySet<string> | undefined
|
|
672
696
|
if (scope.filterStrings.length > 0) {
|
|
673
697
|
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
674
|
-
if ('error' in selected)
|
|
675
|
-
process.stderr.write(`vx run: ${selected.error}\n`)
|
|
676
|
-
return 1
|
|
677
|
-
}
|
|
698
|
+
if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
|
|
678
699
|
if ('empty' in selected) {
|
|
679
700
|
process.stderr.write(`vx run: ${selected.empty}\n`)
|
|
680
701
|
return 0
|
|
@@ -689,10 +710,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
689
710
|
}
|
|
690
711
|
|
|
691
712
|
const resolved = await resolveRunOptions(parsed, cwd, tasks)
|
|
692
|
-
if ('error' in resolved)
|
|
693
|
-
process.stderr.write(`vx run: ${resolved.error}\n`)
|
|
694
|
-
return 1
|
|
695
|
-
}
|
|
713
|
+
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
696
714
|
if ('nothingSelected' in resolved) {
|
|
697
715
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
698
716
|
return 0
|
|
@@ -783,8 +801,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
783
801
|
// is no whole-run delegation seam to consult.
|
|
784
802
|
const summary = await runOrchestrator(opts)
|
|
785
803
|
if (summary.refused !== undefined) {
|
|
786
|
-
|
|
787
|
-
return
|
|
804
|
+
const unknown = summary.refused.startsWith('no projects declare')
|
|
805
|
+
return refuse('run', args, summary.refused, unknown ? 'VX_E_UNKNOWN_TASK' : 'VX_E_REFUSED')
|
|
788
806
|
}
|
|
789
807
|
if (wantsReport) {
|
|
790
808
|
const md =
|
|
@@ -816,5 +834,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
816
834
|
}
|
|
817
835
|
}
|
|
818
836
|
}
|
|
837
|
+
if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
|
|
819
838
|
return summary.ok ? 0 : 1
|
|
820
839
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -10,7 +10,7 @@ import path from 'node:path'
|
|
|
10
10
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
11
11
|
import { declaredTask } from '../graph/index.js'
|
|
12
12
|
import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
|
|
13
|
-
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
13
|
+
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
|
|
15
15
|
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
16
16
|
import {
|
|
@@ -82,10 +82,7 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
82
82
|
|
|
83
83
|
export async function showCmd(args: readonly string[]): Promise<number> {
|
|
84
84
|
const parsed = parseShowArgs(args)
|
|
85
|
-
if (parsed.error)
|
|
86
|
-
process.stderr.write(`vx show: ${parsed.error}\n`)
|
|
87
|
-
return 1
|
|
88
|
-
}
|
|
85
|
+
if (parsed.error) return refuse('show', args, parsed.error, 'VX_E_USAGE')
|
|
89
86
|
const reads: LoadReads = new Map()
|
|
90
87
|
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
91
88
|
const metas = await discoverCliProjects(await loadWorkspace(root, reads))
|
|
@@ -98,6 +95,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
98
95
|
if (rootMeta === undefined) {
|
|
99
96
|
throw new UserError(
|
|
100
97
|
`vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
|
|
98
|
+
'VX_E_USAGE',
|
|
101
99
|
)
|
|
102
100
|
}
|
|
103
101
|
parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
|
|
@@ -114,12 +112,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
114
112
|
const list = projectName === '' ? 'vx show' : `vx show ${projectName}`
|
|
115
113
|
throw new UserError(
|
|
116
114
|
`vx show: missing task name after '#' in "${parsed.target}" (${list} lists the tasks)`,
|
|
115
|
+
'VX_E_USAGE',
|
|
117
116
|
)
|
|
118
117
|
}
|
|
119
118
|
if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
|
|
120
119
|
if (!byName.has(projectName)) {
|
|
121
120
|
throw new UserError(
|
|
122
121
|
`vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
|
|
122
|
+
'VX_E_UNKNOWN_PROJECT',
|
|
123
123
|
)
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -138,18 +138,19 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
138
138
|
if (scope !== 'all') {
|
|
139
139
|
throw new UserError(
|
|
140
140
|
'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
|
|
141
|
+
'VX_E_USAGE',
|
|
141
142
|
)
|
|
142
143
|
}
|
|
143
144
|
const raw = [...parsed.filters]
|
|
144
145
|
let affected: string | undefined
|
|
145
146
|
if (parsed.affected !== undefined) {
|
|
146
147
|
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
147
|
-
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}
|
|
148
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
|
|
148
149
|
affected = f
|
|
149
150
|
raw.unshift(f)
|
|
150
151
|
}
|
|
151
152
|
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
152
|
-
if ('error' in r) throw new UserError(`vx show: ${r.error}
|
|
153
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
|
|
153
154
|
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
154
155
|
selected = new Set('empty' in r ? [] : r.names)
|
|
155
156
|
if ('names' in r && r.affected !== undefined)
|
|
@@ -181,6 +182,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
181
182
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
182
183
|
throw new UserError(
|
|
183
184
|
`vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
|
|
185
|
+
'VX_E_UNKNOWN_TASK',
|
|
184
186
|
)
|
|
185
187
|
}
|
|
186
188
|
let shown = declaring.filter((p) => inSelection(p.name))
|
|
@@ -224,6 +226,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
224
226
|
if (task === undefined) {
|
|
225
227
|
throw new UserError(
|
|
226
228
|
`vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`, 'its tasks')}`,
|
|
229
|
+
'VX_E_UNKNOWN_TASK',
|
|
227
230
|
)
|
|
228
231
|
}
|
|
229
232
|
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function isBunfsPath(p: string): boolean {
|
|
|
38
38
|
* upgrade` refused with "running from source". `Bun.main` stays the
|
|
39
39
|
* bunfs path under every compile-flag combination.
|
|
40
40
|
*/
|
|
41
|
-
function isCompiledBinary(): boolean {
|
|
41
|
+
export function isCompiledBinary(): boolean {
|
|
42
42
|
return (
|
|
43
43
|
isBunfsPath(Bun.main) || isBunfsPath(process.argv[1] ?? '') || isBunfsPath(import.meta.path)
|
|
44
44
|
)
|
package/src/cli/watch.ts
CHANGED
|
@@ -78,8 +78,13 @@ export function watchRefusal(parsed: RunArgs): string | null {
|
|
|
78
78
|
// All three format ONE run's result and are consumed by `runCmd` alone, so
|
|
79
79
|
// a watch loop silently ignored them. `--verbosity 0` is not rejected: it
|
|
80
80
|
// asks for the output watch already gives.
|
|
81
|
-
if (
|
|
82
|
-
|
|
81
|
+
if (
|
|
82
|
+
parsed.report !== undefined ||
|
|
83
|
+
parsed.reportFile !== undefined ||
|
|
84
|
+
parsed.verbosity > 0 ||
|
|
85
|
+
parsed.format !== 'pretty'
|
|
86
|
+
) {
|
|
87
|
+
return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
|
|
83
88
|
}
|
|
84
89
|
return null
|
|
85
90
|
}
|