@vzn/vx 0.0.638 → 0.0.640
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/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/cli/completions.ts
CHANGED
|
@@ -21,8 +21,8 @@ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
|
|
|
21
21
|
/**
|
|
22
22
|
* `--help` and every flag the verb accepts. Not every `--flag` its help cut
|
|
23
23
|
* mentions: the cut's prose names other verbs' flags, and `vx watch`
|
|
24
|
-
* completed only
|
|
25
|
-
*
|
|
24
|
+
* completed only the flags its help line says it refuses, while `vx show`
|
|
25
|
+
* offered `--run` and `--list`.
|
|
26
26
|
*/
|
|
27
27
|
export function verbFlags(verb: string): string[] {
|
|
28
28
|
return [...new Set(['--help', ...acceptedFlags(verb)])]
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -22,8 +22,7 @@ interface ForeignFlag {
|
|
|
22
22
|
/**
|
|
23
23
|
* The runner reads `=true` / `=false` on it (Turbo's `--force [FORCE]`,
|
|
24
24
|
* Nx's yargs booleans): `=false` is the flag left out, `=true` the flag
|
|
25
|
-
* bare
|
|
26
|
-
* wrote the summary to a file named `true`.
|
|
25
|
+
* bare: `--skip-nx-cache=false` forced every task.
|
|
27
26
|
*/
|
|
28
27
|
bool?: true
|
|
29
28
|
/** For an alias: the vx argv. For a refusal: none. */
|
|
@@ -47,8 +46,6 @@ const nxPattern = (p: string): string => {
|
|
|
47
46
|
return p
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
|
|
51
|
-
|
|
52
49
|
export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
53
50
|
{
|
|
54
51
|
runner: 'turbo',
|
|
@@ -98,21 +95,12 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
98
95
|
vx: '`--dry[=text|json]`',
|
|
99
96
|
to: (v) => [v === undefined ? '--dry' : `--dry=${v}`],
|
|
100
97
|
},
|
|
101
|
-
{
|
|
102
|
-
runner: 'turbo',
|
|
103
|
-
names: ['--graph'],
|
|
104
|
-
value: true,
|
|
105
|
-
when: (v) => v !== undefined && GRAPH_IMAGE.test(v),
|
|
106
|
-
shown: '`--graph=<file>.svg|png|json|html|…`',
|
|
107
|
-
outcome: 'refuse',
|
|
108
|
-
vx: 'vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg`',
|
|
109
|
-
},
|
|
110
98
|
{
|
|
111
99
|
runner: 'turbo',
|
|
112
100
|
names: ['--graph'],
|
|
113
101
|
value: false,
|
|
114
|
-
outcome: '
|
|
115
|
-
vx: '`--graph
|
|
102
|
+
outcome: 'refuse',
|
|
103
|
+
vx: '`--dry=json` prints the planned task graph, each task with its deps',
|
|
116
104
|
},
|
|
117
105
|
{
|
|
118
106
|
runner: 'turbo',
|
|
@@ -133,9 +121,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
133
121
|
runner: 'turbo',
|
|
134
122
|
names: ['--summarize'],
|
|
135
123
|
value: false,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
vx: '`--summarize[=<path>]`',
|
|
124
|
+
outcome: 'refuse',
|
|
125
|
+
vx: "use `vx last --format json` for the run's JSON record",
|
|
139
126
|
},
|
|
140
127
|
{
|
|
141
128
|
runner: 'turbo',
|
|
@@ -263,7 +250,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
263
250
|
runner: 'nx',
|
|
264
251
|
names: ['--parallel'],
|
|
265
252
|
value: true,
|
|
266
|
-
|
|
253
|
+
// Nx's value is a count or `false`; a word after it is a task name.
|
|
254
|
+
when: (v) => v === 'false' || (v !== undefined && /^\d+$/.test(v)),
|
|
267
255
|
shown: '`--parallel <n>`',
|
|
268
256
|
outcome: 'alias',
|
|
269
257
|
vx: '`--concurrency <n>` (`--parallel=false` is 1)',
|
|
@@ -355,21 +343,21 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
355
343
|
names: ['--global-deps'],
|
|
356
344
|
value: true,
|
|
357
345
|
outcome: 'refuse',
|
|
358
|
-
vx: "declare them in `
|
|
346
|
+
vx: "declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
|
|
359
347
|
},
|
|
360
348
|
{
|
|
361
349
|
runner: 'turbo',
|
|
362
350
|
names: ['--json'],
|
|
363
351
|
value: false,
|
|
364
352
|
outcome: 'refuse',
|
|
365
|
-
vx: "use
|
|
353
|
+
vx: "use `vx last --format json` for the run's result, `--dry=json` for the plan",
|
|
366
354
|
},
|
|
367
355
|
{
|
|
368
356
|
runner: 'turbo',
|
|
369
357
|
names: ['--log-file'],
|
|
370
358
|
value: false,
|
|
371
359
|
outcome: 'refuse',
|
|
372
|
-
vx: "use
|
|
360
|
+
vx: "use `vx last --format json` for the run's JSON record",
|
|
373
361
|
},
|
|
374
362
|
{
|
|
375
363
|
runner: 'turbo',
|
|
@@ -664,7 +652,7 @@ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
|
|
|
664
652
|
'`nx run-many` is `vx run <task> --all` here; -t, -p, --exclude and --parallel work as they are',
|
|
665
653
|
affected:
|
|
666
654
|
'`nx affected` is `vx run <task> --affected` here; -t, --base and --exclude work as they are',
|
|
667
|
-
graph: '`nx graph` is `vx run <task> --
|
|
655
|
+
graph: '`nx graph` is `vx run <task> --dry=json` here: the planned task graph as JSON',
|
|
668
656
|
ls: '`turbo ls` is `vx show` here: every project, its directory and its task count',
|
|
669
657
|
query:
|
|
670
658
|
'`turbo query` has no vx form: `vx run <task> --dry=json` prints the planned graph as JSON',
|
package/src/cli/help.ts
CHANGED
|
@@ -171,12 +171,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
171
171
|
" --timeout <duration> A task's time limit (30s, 10m), for tasks",
|
|
172
172
|
' without their own exec.timeout. A task past it',
|
|
173
173
|
' is killed and fails.',
|
|
174
|
-
' --kill-grace <duration> How long a task that ignores SIGTERM gets',
|
|
175
|
-
' before SIGKILL (default 2s).',
|
|
176
|
-
" --teardown-timeout <duration> The bound on each plugin's end-of-run flush",
|
|
177
|
-
' (default 3s).',
|
|
178
|
-
' --ready-notice <duration> When vx says a persistent task still waits for',
|
|
179
|
-
' readyWhen (default 10s).',
|
|
180
174
|
' --continue[=<mode>] What a failed task takes down: never | deps-ok',
|
|
181
175
|
' (default, skips its dependents) | always (bare',
|
|
182
176
|
' form; dependents run, never save).',
|
|
@@ -190,18 +184,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
190
184
|
'Planning (for run — skips execution):',
|
|
191
185
|
" --dry[=text|json] Print the task graph and each task's predicted hit",
|
|
192
186
|
' or miss.',
|
|
193
|
-
' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
|
|
194
187
|
'',
|
|
195
188
|
'Artifacts (for run):',
|
|
196
|
-
' --format <fmt> pretty (default) | json: print the --summarize',
|
|
197
|
-
' document on stdout when the run ends; every other',
|
|
198
|
-
' line goes to stderr.',
|
|
199
|
-
' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
|
|
200
189
|
' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
|
|
201
|
-
' --report[=markdown] Print a markdown run report to stdout after the run.',
|
|
202
|
-
' --report-file <path> Append that report to <path> ($GITHUB_STEP_SUMMARY).',
|
|
203
|
-
' --tag <k=v> Label this invocation (repeatable); recorded on its',
|
|
204
|
-
' history row.',
|
|
205
190
|
'',
|
|
206
191
|
'Extensions (plugins):',
|
|
207
192
|
' vx core runs tasks in-process and nothing else. A dashboard, remote',
|
|
@@ -223,12 +208,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
223
208
|
'Watch mode:',
|
|
224
209
|
' vx watch <task> Initial run, then re-run on filesystem changes.',
|
|
225
210
|
' Uses the same flags as `vx run` except --dry /',
|
|
226
|
-
' --
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
' outputs + replay logs).',
|
|
211
|
+
' --profile / --verbosity (they plan or report one',
|
|
212
|
+
' run), plus --poll (watch.poll: poll instead of',
|
|
213
|
+
' the OS watcher). Press Ctrl+C to stop.',
|
|
214
|
+
' Cache means most re-runs are near-zero cost',
|
|
215
|
+
' (cache hits restore outputs + replay logs).',
|
|
232
216
|
'',
|
|
233
217
|
'Cache management:',
|
|
234
218
|
' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
|
|
@@ -402,16 +386,7 @@ export function documentedFlags(verb: string, text = helpText()): string[] {
|
|
|
402
386
|
* The `vx run` flags `vx watch` refuses: each plans or reports ONE run
|
|
403
387
|
* (`watchRefusal` in watch.ts, held to this list by a test).
|
|
404
388
|
*/
|
|
405
|
-
export const WATCH_REFUSED_FLAGS: readonly string[] = [
|
|
406
|
-
'--dry',
|
|
407
|
-
'--graph',
|
|
408
|
-
'--summarize',
|
|
409
|
-
'--profile',
|
|
410
|
-
'--report',
|
|
411
|
-
'--report-file',
|
|
412
|
-
'--verbosity',
|
|
413
|
-
'--format',
|
|
414
|
-
]
|
|
389
|
+
export const WATCH_REFUSED_FLAGS: readonly string[] = ['--dry', '--profile', '--verbosity']
|
|
415
390
|
|
|
416
391
|
/**
|
|
417
392
|
* The flags one verb accepts: those on its own Usage line, and for a verb
|
package/src/cli/info.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// from the orchestrator's `collectInfo` (doctor.ts); this file parses the flags and renders the rows.
|
|
5
5
|
|
|
6
6
|
import os from 'node:os'
|
|
7
|
-
import { collectInfo, type
|
|
7
|
+
import { collectInfo, type InfoFacts } from '../orchestrator/index.js'
|
|
8
|
+
import { type FlakyTask } from '../history/index.js'
|
|
8
9
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
9
10
|
import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
10
11
|
import { formatBytes } from './format.js'
|
package/src/cli/init.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
|
|
|
13
13
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { lockAfterMigration } from './lock.js'
|
|
15
15
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
16
|
-
import { isCompiledBinary } from './upgrade.js'
|
|
16
|
+
import { isCompiledBinary, targetVersion } from './upgrade.js'
|
|
17
17
|
import { VERSION } from '../version.js'
|
|
18
18
|
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
19
|
import {
|
|
@@ -217,8 +217,11 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* A repo with a package.json is `@vzn/vx-migrate`'s
|
|
221
|
-
*
|
|
220
|
+
* A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
|
|
221
|
+
* release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
|
|
222
|
+
* at its own version, so init always lands on latest with all of them
|
|
223
|
+
* together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
|
|
224
|
+
* pinned core back from 0.0.639). It finds the root itself, asks a
|
|
222
225
|
* terminal native or keep in a Turbo or Nx repo, and installs what it writes.
|
|
223
226
|
*/
|
|
224
227
|
async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
|
|
@@ -229,21 +232,30 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
|
|
|
229
232
|
args.mode !== undefined && `--${args.mode}`,
|
|
230
233
|
args.json === true && '--format=json',
|
|
231
234
|
].filter((f): f is string => typeof f === 'string')
|
|
232
|
-
|
|
235
|
+
// An exact version: `bun x` may answer `@latest` from its cache.
|
|
236
|
+
const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
|
|
237
|
+
return Bun.spawn(migrateCommand(dirs, flags, latest), {
|
|
233
238
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
234
239
|
// A compiled vx is a Bun runtime only when told so.
|
|
235
240
|
env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
|
|
236
241
|
}).exited
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
export function migrateCommand(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
export function migrateCommand(
|
|
245
|
+
dirs: readonly string[],
|
|
246
|
+
flags: readonly string[],
|
|
247
|
+
latest: string,
|
|
248
|
+
running = VERSION,
|
|
249
|
+
): string[] {
|
|
250
|
+
// A source checkout (0.0.0, which npm never had) runs the vx-migrate
|
|
251
|
+
// beside it; a release never runs an installed one.
|
|
252
|
+
if (running === '0.0.0') {
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
|
|
255
|
+
if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
|
|
256
|
+
}
|
|
243
257
|
}
|
|
244
|
-
// A source checkout is 0.0.0, which npm never had.
|
|
245
|
-
const version = VERSION === '0.0.0' ? 'latest' : VERSION
|
|
246
258
|
// Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
|
|
247
259
|
// repo reads as a stranger speaking (Growth's first-run funnel F2).
|
|
248
|
-
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${
|
|
260
|
+
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${latest}`, ...flags]
|
|
249
261
|
}
|
package/src/cli/last.ts
CHANGED
|
@@ -9,21 +9,25 @@ import { Cache } from '../cache/index.js'
|
|
|
9
9
|
import { formatBytes } from './format.js'
|
|
10
10
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
11
11
|
import {
|
|
12
|
-
currentBranch,
|
|
13
|
-
exitSignal,
|
|
14
12
|
type FailureMode,
|
|
15
13
|
getInvocation,
|
|
16
14
|
getRun,
|
|
17
15
|
type InvocationDetail,
|
|
18
16
|
listInvocations,
|
|
19
17
|
LocalHistoryProvider,
|
|
20
|
-
outcomeWord,
|
|
21
18
|
type RunSummaryRow,
|
|
19
|
+
} from '../history/index.js'
|
|
20
|
+
import {
|
|
21
|
+
currentBranch,
|
|
22
|
+
exitSignal,
|
|
22
23
|
resolveRunId,
|
|
23
24
|
runFailures,
|
|
25
|
+
type RunStep,
|
|
26
|
+
runSteps,
|
|
24
27
|
shortRunId,
|
|
25
28
|
taskLog,
|
|
26
29
|
} from '../orchestrator/index.js'
|
|
30
|
+
import { outcomeWord } from '../report/index.js'
|
|
27
31
|
import { formatElapsed, refusal, splitTaskId, UserError, sayNotice, ui } from '../util/index.js'
|
|
28
32
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
29
33
|
import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
|
|
@@ -319,12 +323,29 @@ function reasonParts(t: RunSummaryRow): string {
|
|
|
319
323
|
} else if (t.status === 'skipped' && t.blockedBy !== null) {
|
|
320
324
|
parts.push(`after ${t.blockedBy} failed`)
|
|
321
325
|
}
|
|
326
|
+
if (t.remoteError !== null) parts.push(`remote \u2717 ${t.remoteError}`)
|
|
322
327
|
return parts.map((p) => ` ${p}`).join('')
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/** Hit rows shown before the rest fold — the slowest restores. */
|
|
326
331
|
const HITS_SHOWN = 16
|
|
327
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The steps the run did not wait on (`vx:prefetch`), which its own output
|
|
335
|
+
* never showed (M, 2026-10-16): here for debugging, marked `internal`.
|
|
336
|
+
*/
|
|
337
|
+
function formatStepRows(steps: readonly RunStep[]): string[] {
|
|
338
|
+
if (steps.length === 0) return []
|
|
339
|
+
const idW = Math.max(...steps.map((s) => s.id.length), 4)
|
|
340
|
+
return [
|
|
341
|
+
'',
|
|
342
|
+
...steps.map(
|
|
343
|
+
(s) =>
|
|
344
|
+
` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
|
|
345
|
+
),
|
|
346
|
+
]
|
|
347
|
+
}
|
|
348
|
+
|
|
328
349
|
/**
|
|
329
350
|
* The per-task rows: failures first, then what executed or was skipped,
|
|
330
351
|
* then cache hits. Hits are the noise of a warm run — 996 of a thousand
|
|
@@ -333,6 +354,7 @@ const HITS_SHOWN = 16
|
|
|
333
354
|
* count; the ones shown are the slowest restores, the one thing a hit's
|
|
334
355
|
* row tells. `--format json` lists every row.
|
|
335
356
|
*/
|
|
357
|
+
|
|
336
358
|
export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
337
359
|
if (tasks.length === 0) return []
|
|
338
360
|
const failed = tasks.filter((t) => t.status === 'failed')
|
|
@@ -542,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
542
564
|
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
543
565
|
}
|
|
544
566
|
})
|
|
545
|
-
|
|
567
|
+
const steps = runSteps(cacheDir, inv.runId)
|
|
568
|
+
process.stdout.write(
|
|
569
|
+
`${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
|
|
570
|
+
)
|
|
546
571
|
return 0
|
|
547
572
|
}
|
|
548
573
|
|
|
@@ -560,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
560
585
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
561
586
|
)
|
|
562
587
|
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
|
588
|
+
lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
|
|
563
589
|
// The next command after reading a failed run, with the arguments the
|
|
564
590
|
// run forwarded to the tasks that got them (a failure under `-- --shard
|
|
565
591
|
// 2` is that shard's); a dependency never got them, so its line has none.
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Formatters for `--dry
|
|
1
|
+
// Formatters for `--dry` (human / JSON).
|
|
2
2
|
|
|
3
3
|
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
4
|
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
@@ -9,7 +9,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
|
9
9
|
* task with recorded history also shows its typical duration (~p50).
|
|
10
10
|
*
|
|
11
11
|
* would run:
|
|
12
|
-
*
|
|
12
|
+
* ✓ @vzn/vx#lint cache hit (local) abc12345
|
|
13
13
|
* ↓ @vzn/vx#test cache hit (remote) def67890
|
|
14
14
|
* ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
|
|
15
15
|
*
|
|
@@ -33,7 +33,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
33
33
|
for (const t of real) {
|
|
34
34
|
// `@noop`: the run succeeds it without running, whatever its key says.
|
|
35
35
|
const skipped = t.executor === 'noop'
|
|
36
|
-
const sym = skipped ? '
|
|
36
|
+
const sym = skipped ? '·' : symbolFor(t.cacheStatus)
|
|
37
37
|
const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
|
|
38
38
|
const shortHash = t.hash ? t.hash.slice(0, 8) : ''
|
|
39
39
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
@@ -186,52 +186,16 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
|
|
|
186
186
|
)
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/**
|
|
190
|
-
* Render the task graph as a Graphviz DOT document. Includes group
|
|
191
|
-
* nodes — they're real nodes in the graph (just not real units of
|
|
192
|
-
* work), and a `--graph` consumer wants to see the whole structure.
|
|
193
|
-
*
|
|
194
|
-
* Layout: `rankdir=LR`. Node fillcolor varies by predicted cache
|
|
195
|
-
* status so a quick visual scan tells you which boxes get to skip
|
|
196
|
-
* actual work.
|
|
197
|
-
*/
|
|
198
|
-
export function formatGraphDot(plan: RunPlan): string {
|
|
199
|
-
const lines: string[] = ['digraph TaskGraph {', ' rankdir=LR;', ' node [shape=box];']
|
|
200
|
-
for (const t of plan.tasks) {
|
|
201
|
-
const color = dotColor(t.cacheStatus)
|
|
202
|
-
lines.push(
|
|
203
|
-
` ${dotString(t.node.id)} [label=${dotString(`${t.node.id}\n${t.hash.slice(0, 8)}`)}, style="filled", fillcolor="${color}"];`,
|
|
204
|
-
)
|
|
205
|
-
}
|
|
206
|
-
for (const t of plan.tasks) {
|
|
207
|
-
for (const dep of t.deps) {
|
|
208
|
-
lines.push(` ${dotString(dep)} -> ${dotString(t.node.id)};`)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
lines.push('}')
|
|
212
|
-
return lines.join('\n') + '\n'
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* A DOT double-quoted ID. A task name is any object key in a config, so
|
|
217
|
-
* `"`, `\` and a newline are all reachable; unescaped they end the
|
|
218
|
-
* string early and the document stops parsing. `\n` is DOT's own line
|
|
219
|
-
* break inside a label.
|
|
220
|
-
*/
|
|
221
|
-
function dotString(s: string): string {
|
|
222
|
-
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
|
|
223
|
-
}
|
|
224
|
-
|
|
225
189
|
function symbolFor(s: CacheStatus): string {
|
|
226
190
|
switch (s) {
|
|
227
191
|
case 'hit-local':
|
|
228
|
-
return '
|
|
192
|
+
return '✓'
|
|
229
193
|
case 'hit-remote':
|
|
230
194
|
return '↓'
|
|
231
195
|
case 'miss':
|
|
232
196
|
return '▶'
|
|
233
197
|
case 'no-cache':
|
|
234
|
-
return '
|
|
198
|
+
return '∅'
|
|
235
199
|
case 'group':
|
|
236
200
|
return '○'
|
|
237
201
|
case 'not-started':
|
|
@@ -255,20 +219,3 @@ function describe(s: CacheStatus): string {
|
|
|
255
219
|
return 'not started (unneeded)'
|
|
256
220
|
}
|
|
257
221
|
}
|
|
258
|
-
|
|
259
|
-
function dotColor(s: CacheStatus): string {
|
|
260
|
-
switch (s) {
|
|
261
|
-
case 'hit-local':
|
|
262
|
-
return '#bbf7d0' // green-200
|
|
263
|
-
case 'hit-remote':
|
|
264
|
-
return '#bae6fd' // sky-200
|
|
265
|
-
case 'miss':
|
|
266
|
-
return '#fed7aa' // orange-200
|
|
267
|
-
case 'no-cache':
|
|
268
|
-
return '#e5e7eb' // gray-200
|
|
269
|
-
case 'group':
|
|
270
|
-
return '#f5d0fe' // fuchsia-200
|
|
271
|
-
case 'not-started':
|
|
272
|
-
return '#ffffff'
|
|
273
|
-
}
|
|
274
|
-
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// reports an unknown command as it always did. Core's verbs are matched
|
|
8
8
|
// before this is asked, so a plugin cannot shadow `run`.
|
|
9
9
|
|
|
10
|
-
import type { CachePolicy, PluginCache,
|
|
10
|
+
import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
|
|
11
11
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
12
|
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
13
13
|
import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
|
|
@@ -151,7 +151,7 @@ async function verbCache(
|
|
|
151
151
|
'open',
|
|
152
152
|
await ws.storeRoot,
|
|
153
153
|
)
|
|
154
|
-
const remotes:
|
|
154
|
+
const remotes: ResolvedRemote[] = []
|
|
155
155
|
await resolveCache(ws.plugins, {
|
|
156
156
|
workspaceRoot: ws.workspaceRoot,
|
|
157
157
|
cacheDir: ws.cacheDir,
|
|
@@ -11,7 +11,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
13
|
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
|
|
14
|
-
test: "// A test for the example `cache` 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-cache-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n
|
|
14
|
+
test: "// A test for the example `cache` 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-cache-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
@@ -31,7 +31,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
31
31
|
key: {
|
|
32
32
|
plugin:
|
|
33
33
|
"// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
|
|
34
|
-
test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n
|
|
34
|
+
test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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 // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\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(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
|
|
35
35
|
},
|
|
36
36
|
project: {
|
|
37
37
|
plugin:
|