@vzn/vx 0.0.631 → 0.0.633
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -18
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -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 +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- 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/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- 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 +19 -7
- 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 +4 -4
- 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 +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- 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/projects.ts +2 -2
- 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/task-hash.ts +4 -4
- 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/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
package/src/cli/init.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
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 [--format json]] [--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
|
|
|
9
10
|
import { existsSync, readFileSync } from 'node:fs'
|
|
10
11
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
11
12
|
import path from 'node:path'
|
|
12
|
-
import { flagHint, seeHelp } from './help.js'
|
|
13
|
+
import { flagHint, formatValue, refuse, 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,10 @@ 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'
|
|
37
|
+
/** `--format json` (with `--dry`): the plan as one JSON document. */
|
|
38
|
+
json?: boolean
|
|
32
39
|
error?: string
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -46,13 +53,29 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
out.plugin = seam
|
|
56
|
+
} else if (a === '--format' || a.startsWith('--format=')) {
|
|
57
|
+
const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'init')
|
|
58
|
+
if (typeof fv === 'object') return { ...out, error: fv.error }
|
|
59
|
+
out.json = fv === 'json'
|
|
49
60
|
} else if (a === '--dry') out.dry = true
|
|
50
61
|
else if (a === '--force') out.force = true
|
|
51
62
|
else if (a === '--mjs') out.mjs = true
|
|
52
|
-
else if (a
|
|
63
|
+
else if (a === '--native' || a === '--keep') {
|
|
64
|
+
const mode = a === '--native' ? 'native' : 'keep'
|
|
65
|
+
if (out.mode !== undefined && out.mode !== mode) {
|
|
66
|
+
return {
|
|
67
|
+
...out,
|
|
68
|
+
error: `--native and --keep are two answers to one question; pass one${seeHelp('init')}`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
out.mode = mode
|
|
72
|
+
} else if (a.startsWith('-'))
|
|
53
73
|
return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
|
|
54
74
|
else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
|
|
55
75
|
}
|
|
76
|
+
if (out.plugin !== undefined && out.mode !== undefined) {
|
|
77
|
+
return { ...out, error: `--${out.mode} does not combine with --plugin${seeHelp('init')}` }
|
|
78
|
+
}
|
|
56
79
|
if (out.plugin !== undefined && out.mjs) {
|
|
57
80
|
return {
|
|
58
81
|
...out,
|
|
@@ -64,16 +87,22 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
64
87
|
|
|
65
88
|
export async function initCmd(args: readonly string[]): Promise<number> {
|
|
66
89
|
const parsed = parseInitArgs(args)
|
|
67
|
-
if (parsed.error)
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
if (parsed.error) return refuse('init', args, parsed.error, 'VX_E_USAGE')
|
|
91
|
+
// In the verb, not the parser: completion asks the parser one flag at a time.
|
|
92
|
+
if (parsed.json === true && (!parsed.dry || parsed.plugin !== undefined)) {
|
|
93
|
+
return refuse(
|
|
94
|
+
'init',
|
|
95
|
+
args,
|
|
96
|
+
`--format json prints the workspace plan, so it needs --dry and no --plugin${seeHelp('init')}`,
|
|
97
|
+
'VX_E_USAGE',
|
|
98
|
+
)
|
|
70
99
|
}
|
|
71
100
|
if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
|
|
72
101
|
const reads: LoadReads = new Map()
|
|
73
102
|
// "a workspace from nowhere" still needs a package.json to start from,
|
|
74
103
|
// and the lookup's own refusal named no next step.
|
|
75
104
|
const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
|
|
76
|
-
if (isUserError(err) && err.
|
|
105
|
+
if (isUserError(err) && err.code === 'VX_E_NO_WORKSPACE') {
|
|
77
106
|
throw new UserError(
|
|
78
107
|
'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
108
|
)
|
|
@@ -95,14 +124,42 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
95
124
|
break
|
|
96
125
|
}
|
|
97
126
|
}
|
|
98
|
-
// A Turbo or Nx repo already says its tasks:
|
|
99
|
-
// the
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
// A Turbo or Nx repo already says its tasks: writing a config per package
|
|
128
|
+
// from the scripts dropped every edge turbo.json declares (the
|
|
129
|
+
// first-five-minutes walk, 2026-09-28). vx-migrate maps the runner's own
|
|
130
|
+
// config, so `vx init` is the one command (M, 2026-10-08); its `--keep`
|
|
131
|
+
// comes back here for the workspace file alone.
|
|
132
|
+
const runner: ['turbo' | 'nx', string] | undefined =
|
|
133
|
+
turbo !== undefined
|
|
134
|
+
? ['turbo', turbo]
|
|
135
|
+
: (await Bun.file(path.join(root, 'nx.json')).exists())
|
|
136
|
+
? ['nx', 'nx.json']
|
|
137
|
+
: undefined
|
|
138
|
+
if (runner !== undefined) {
|
|
139
|
+
if (parsed.mode === 'keep') return adopt(root, ...runner, parsed)
|
|
140
|
+
// A second `vx init` after the native migration refused to overwrite
|
|
141
|
+
// the configs it wrote, exit 1, after a package fetch: the first-run
|
|
142
|
+
// walk (2026-10-09). Set up is an answer, with the next step.
|
|
143
|
+
const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
|
|
144
|
+
if (
|
|
145
|
+
existing !== undefined &&
|
|
146
|
+
!parsed.force &&
|
|
147
|
+
!parsed.dry &&
|
|
148
|
+
parsed.mode === undefined &&
|
|
149
|
+
metas.some((m) => m.configPath !== null) &&
|
|
150
|
+
!readFileSync(path.join(root, existing), 'utf8').includes(`${runner[0]}(`)
|
|
151
|
+
) {
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
`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`,
|
|
154
|
+
)
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
return migrate(root, parsed)
|
|
158
|
+
}
|
|
159
|
+
if (parsed.mode !== undefined) {
|
|
160
|
+
throw new UserError(
|
|
161
|
+
`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) || '.'}`,
|
|
162
|
+
)
|
|
106
163
|
}
|
|
107
164
|
const unmapped = namelessNotes(root, nameless)
|
|
108
165
|
return applyMigration({
|
|
@@ -118,6 +175,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
118
175
|
notes: [...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
|
|
119
176
|
unmapped: unmapped.length > 0,
|
|
120
177
|
dry: parsed.dry,
|
|
178
|
+
json: parsed.json === true,
|
|
121
179
|
force: parsed.force,
|
|
122
180
|
init: true,
|
|
123
181
|
format: parsed.mjs ? 'mjs' : 'ts',
|
|
@@ -156,7 +214,36 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
|
|
|
156
214
|
}
|
|
157
215
|
|
|
158
216
|
/**
|
|
159
|
-
* `vx init` in a Turbo or Nx repo
|
|
217
|
+
* `vx init` in a Turbo or Nx repo runs `@vzn/vx-migrate`: the installed one,
|
|
218
|
+
* else this version's through `bun x`. It asks a terminal native or keep,
|
|
219
|
+
* installs what it writes, and runs `vx init --keep` for keep.
|
|
220
|
+
*/
|
|
221
|
+
async function migrate(root: string, args: InitArgs): Promise<number> {
|
|
222
|
+
const flags = [
|
|
223
|
+
args.dry && '--dry',
|
|
224
|
+
args.force && '--force',
|
|
225
|
+
args.mjs && '--mjs',
|
|
226
|
+
args.mode === 'native' && '--native',
|
|
227
|
+
args.json === true && '--format=json',
|
|
228
|
+
].filter((f): f is string => typeof f === 'string')
|
|
229
|
+
return Bun.spawn(migrateCommand(root, flags), {
|
|
230
|
+
cwd: root,
|
|
231
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
232
|
+
// A compiled vx is a Bun runtime only when told so.
|
|
233
|
+
env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
|
|
234
|
+
}).exited
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function migrateCommand(root: string, flags: readonly string[]): string[] {
|
|
238
|
+
const installed = path.join(root, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
|
|
239
|
+
if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
|
|
240
|
+
// A source checkout is 0.0.0, which npm never had.
|
|
241
|
+
const version = VERSION === '0.0.0' ? 'latest' : VERSION
|
|
242
|
+
return [process.execPath, 'x', `@vzn/vx-migrate@${version}`, ...flags]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `vx init --keep` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
|
|
160
247
|
* that runs the repo as it is, nothing else, and the one command that gets
|
|
161
248
|
* from here to a run.
|
|
162
249
|
*/
|
|
@@ -184,7 +271,7 @@ async function adopt(
|
|
|
184
271
|
)
|
|
185
272
|
}
|
|
186
273
|
} else if (args.dry) {
|
|
187
|
-
process.stdout.write(`── ${name} ──\n${text}\n`)
|
|
274
|
+
if (args.json !== true) process.stdout.write(`── ${name} ──\n${text}\n`)
|
|
188
275
|
} else {
|
|
189
276
|
if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
|
|
190
277
|
await Bun.write(path.join(root, name), text)
|
|
@@ -205,8 +292,23 @@ async function adopt(
|
|
|
205
292
|
: kept
|
|
206
293
|
? ''
|
|
207
294
|
: `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
|
|
295
|
+
if (args.json === true) {
|
|
296
|
+
process.stdout.write(
|
|
297
|
+
`${JSON.stringify({
|
|
298
|
+
dry: true,
|
|
299
|
+
source,
|
|
300
|
+
files: kept ? [] : [{ path: name, contents: text }],
|
|
301
|
+
kept: kept ? [existing] : [],
|
|
302
|
+
replaced: !kept && existing !== undefined && existing !== name ? [existing] : [],
|
|
303
|
+
todos: [],
|
|
304
|
+
notes: cacheLine === '' ? [] : [cacheLine.trimEnd()],
|
|
305
|
+
next: adoptionNext(root, runner, source),
|
|
306
|
+
})}\n`,
|
|
307
|
+
)
|
|
308
|
+
return 0
|
|
309
|
+
}
|
|
208
310
|
process.stdout.write(
|
|
209
|
-
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until
|
|
311
|
+
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
|
|
210
312
|
`${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
|
|
211
313
|
)
|
|
212
314
|
return 0
|
|
@@ -320,9 +422,14 @@ const INSTALL: Record<PackageManager, string> = {
|
|
|
320
422
|
const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
|
|
321
423
|
|
|
322
424
|
/** Install what the workspace file imports, if missing, then run the repo's build. */
|
|
323
|
-
export function adoptionNext(
|
|
425
|
+
export function adoptionNext(
|
|
426
|
+
root: string,
|
|
427
|
+
runner: 'turbo' | 'nx',
|
|
428
|
+
source: string,
|
|
429
|
+
needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
|
|
430
|
+
): string {
|
|
324
431
|
const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
|
|
325
|
-
const missing =
|
|
432
|
+
const missing = needs.filter(
|
|
326
433
|
(p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
|
|
327
434
|
)
|
|
328
435
|
// 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/lock.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
|
-
import { flagHint, refusedWord, seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
7
|
import { relPosix, secretMask, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
@@ -22,16 +22,22 @@ import { discoverCliProjects } from './workspace-config.js'
|
|
|
22
22
|
|
|
23
23
|
interface LockArgs {
|
|
24
24
|
check: boolean
|
|
25
|
+
format: 'pretty' | 'json'
|
|
25
26
|
error?: string
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
29
|
-
const out: LockArgs = { check: false }
|
|
30
|
-
for (
|
|
30
|
+
const out: LockArgs = { check: false, format: 'pretty' }
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i]!
|
|
31
33
|
if (a === '--check') out.check = true
|
|
32
|
-
else
|
|
34
|
+
else if (a === '--format' || a.startsWith('--format=')) {
|
|
35
|
+
const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'lock')
|
|
36
|
+
if (typeof fv === 'object') return { ...out, ...fv }
|
|
37
|
+
out.format = fv
|
|
38
|
+
} else
|
|
33
39
|
return {
|
|
34
|
-
|
|
40
|
+
...out,
|
|
35
41
|
error: `${refusedWord(a)}: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
|
|
36
42
|
}
|
|
37
43
|
}
|
|
@@ -42,10 +48,15 @@ type ConfiguredMeta = ProjectMeta & { configPath: string }
|
|
|
42
48
|
|
|
43
49
|
export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
44
50
|
const parsed = parseLockArgs(args)
|
|
45
|
-
if (parsed.error)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
|
|
52
|
+
// Writing the lock prints one line; only the audit has an answer to shape.
|
|
53
|
+
if (parsed.format === 'json' && !parsed.check)
|
|
54
|
+
return refuse(
|
|
55
|
+
'lock',
|
|
56
|
+
args,
|
|
57
|
+
`--format json answers --check only${seeHelp('lock')}`,
|
|
58
|
+
'VX_E_USAGE',
|
|
59
|
+
)
|
|
49
60
|
const reads: LoadReads = new Map()
|
|
50
61
|
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
51
62
|
const workspace = await loadWorkspace(root, reads)
|
|
@@ -62,7 +73,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
62
73
|
// count above leaves out — `locked 0 project configs` on a turbo() repo
|
|
63
74
|
// read like an audit of something (2026-09-16).
|
|
64
75
|
const bare = all.length - metas.length
|
|
65
|
-
if (parsed.check) return await checkLock(root, metas, bare)
|
|
76
|
+
if (parsed.check) return await checkLock(root, metas, bare, parsed.format === 'json')
|
|
66
77
|
return await writeLock(root, metas, bare)
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -154,13 +165,29 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
|
|
|
154
165
|
* catches eval-time env-var drift that file hashes cannot see —
|
|
155
166
|
* file bytes unchanged, resolved value changed.
|
|
156
167
|
*/
|
|
157
|
-
async function checkLock(
|
|
168
|
+
async function checkLock(
|
|
169
|
+
root: string,
|
|
170
|
+
metas: ConfiguredMeta[],
|
|
171
|
+
bare: number,
|
|
172
|
+
json: boolean,
|
|
173
|
+
): Promise<number> {
|
|
174
|
+
const answer = (problems: string[]): number => {
|
|
175
|
+
if (json) {
|
|
176
|
+
const out = {
|
|
177
|
+
upToDate: problems.length === 0,
|
|
178
|
+
audited: metas.length,
|
|
179
|
+
notAudited: bare,
|
|
180
|
+
problems,
|
|
181
|
+
}
|
|
182
|
+
process.stdout.write(`${JSON.stringify(out)}\n`)
|
|
183
|
+
}
|
|
184
|
+
return problems.length === 0 ? 0 : 1
|
|
185
|
+
}
|
|
158
186
|
const lock = await readLockfile(root)
|
|
159
187
|
if (!lock) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
return 1
|
|
188
|
+
const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
|
|
189
|
+
if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
|
|
190
|
+
return answer([problem])
|
|
164
191
|
}
|
|
165
192
|
const results = await Promise.all(
|
|
166
193
|
metas.map(async (m): Promise<string | null> => {
|
|
@@ -200,6 +227,7 @@ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
200
227
|
)
|
|
201
228
|
}
|
|
202
229
|
}
|
|
230
|
+
if (json) return answer(failures)
|
|
203
231
|
if (failures.length > 0) {
|
|
204
232
|
for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
|
|
205
233
|
return 1
|
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:
|