@vzn/vx 0.0.100 → 0.0.102

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 CHANGED
@@ -38,10 +38,7 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
38
38
  fully cached run replays the graph in 510 ms.
39
39
  <!-- bench:end -->
40
40
 
41
- On a real Turbo repo (`solidjs/solid`, its own `turbo.json`, nothing
42
- rewritten) vx replays a cached build in 51 ms where Turbo takes 95,
43
- restores every output in 66 ms against 127, and builds cold in 40.6 s
44
- against 45.5 s ([benchmarks](packages/vx/docs/benchmarks.md)).
41
+ Measured on eleven real Turborepo and Nx repos too: [tried on real repos](#tried-on-real-repos).
45
42
 
46
43
  ## Install
47
44
 
@@ -96,15 +93,15 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
96
93
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
97
94
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
98
95
 
99
- ## Already on Turborepo or Nx?
96
+ ## Already on Turborepo, Nx or moon?
100
97
 
101
- Keep your `turbo.json` or Nx project graph. Two lines run it under vx,
102
- with nothing rewritten:
98
+ Keep your `turbo.json`, Nx project graph or `.moon/`. Two lines run it
99
+ under vx, with nothing rewritten:
103
100
 
104
101
  ```ts
105
102
  // vx.workspace.ts
106
103
  import { defineWorkspace } from '@vzn/vx'
107
- import { turbo } from '@vzn/vx-migrate' // or: nx
104
+ import { turbo } from '@vzn/vx-migrate' // or: nx, moon
108
105
 
109
106
  export default defineWorkspace({ plugins: [turbo()] })
110
107
  ```
@@ -112,7 +109,7 @@ export default defineWorkspace({ plugins: [turbo()] })
112
109
  When you want configs of your own, one command writes them:
113
110
 
114
111
  ```sh
115
- bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
112
+ bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json, an Nx graph or .moon/
116
113
  ```
117
114
 
118
115
  The written configs derive the same cache keys, so what `turbo()` cached
@@ -146,39 +143,73 @@ Turbo and Nx feature is in vx, pinned by a test:
146
143
  Vite does it. Core ships none and needs none.
147
144
  - **Free.** MIT. No paywall, no cloud, no account.
148
145
 
146
+ ## Tried on real repos
147
+
148
+ Eleven public monorepos, each run with its own `turbo.json` or Nx graph
149
+ and nothing rewritten, against the tool it ships with. Both tools on the same machine and
150
+ tasks per row, medians; vx first, theirs second. Where the other tool
151
+ wins, the row says so.
152
+
153
+ | Repo | Against | Cold build | Restore outputs | Nothing to do |
154
+ | ----------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
155
+ | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
156
+ | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
157
+ | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
158
+ | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
159
+ | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
160
+ | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
161
+ | [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
162
+ | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
163
+ | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
164
+ | [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
165
+ | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
166
+
167
+ **Bold** marks the other tool winning. Versions, revisions, scopes and
168
+ harness per repo: [benchmarks](packages/vx/docs/benchmarks.md) (astro
169
+ and refine as re-measured on 2026-09-27, the rest 2026-09-10 and -11).
170
+
149
171
  ## How it compares
150
172
 
151
- | | vx | Turborepo | Nx |
152
- | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
153
- | Fully cached, 1,090 pkgs¹ | **510 ms** | 760 ms | 3.59 s |
154
- | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
155
- | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
156
- | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
157
- | Daemon required for speed | **No** | Optional | Yes |
158
- | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
159
- | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
160
- | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid |
161
- | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
173
+ | | vx | Turborepo | Nx | moon² |
174
+ | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- | ------------------------------ |
175
+ | Fully cached, 1,090 pkgs¹ | **510 ms** | 760 ms | 3.59 s | not measured |
176
+ | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) | YAML, Pkl and others (static) |
177
+ | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive | Glob outputs pruned on restore |
178
+ | Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon | `git hash-object` |
179
+ | Daemon required for speed | **No** | Optional | Yes | Optional (experimental) |
180
+ | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No | No |
181
+ | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) | WASM toolchains, extensions |
182
+ | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid | No |
183
+ | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node | Single binary |
162
184
 
163
185
  ¹ Warm, nothing to rebuild, the graph charted above. Every gap, and
164
186
  when another tool is the better pick:
165
187
  [comparison](packages/vx/docs/comparison.md) ·
166
188
  [vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
167
189
 
190
+ ² From moon's own docs (2026-09-28): [config](https://github.com/moonrepo/moon/blob/master/website/docs/config/overview.mdx),
191
+ [outputs](https://github.com/moonrepo/moon/blob/master/website/docs/config/project.mdx),
192
+ [hashing](https://github.com/moonrepo/moon/blob/master/website/docs/guides/debug-task.mdx),
193
+ [daemon](https://github.com/moonrepo/moon/blob/master/website/docs/guides/daemon.mdx),
194
+ [plugins](https://github.com/moonrepo/moon/blob/master/website/docs/guides/wasm-plugins.mdx),
195
+ [install](https://github.com/moonrepo/moon/blob/master/website/docs/install.mdx);
196
+ they describe no per-task sandbox and no OpenTelemetry export. Already on
197
+ moon: `moon()` runs it unchanged.
198
+
168
199
  ## Plugins
169
200
 
170
201
  Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
171
202
  machine.
172
203
 
173
- | Package | What it adds |
174
- | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
175
- | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo or Nx repo unchanged; write configs; keep a Turbo or Nx cache |
176
- | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
177
- | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
178
- | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
179
- | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
180
- | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
181
- | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
204
+ | Package | What it adds |
205
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
206
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo, Nx or moon repo unchanged; write configs; keep a Turbo or Nx cache |
207
+ | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
208
+ | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
209
+ | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
210
+ | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
211
+ | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
212
+ | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
182
213
 
183
214
  Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
184
215
  [architecture](packages/vx/docs/architecture.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.100",
3
+ "version": "0.0.102",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.100",
21
- "@vzn/vx-linux-arm64": "0.0.100",
22
- "@vzn/vx-darwin-x64": "0.0.100",
23
- "@vzn/vx-darwin-arm64": "0.0.100"
20
+ "@vzn/vx-linux-x64": "0.0.102",
21
+ "@vzn/vx-linux-arm64": "0.0.102",
22
+ "@vzn/vx-darwin-x64": "0.0.102",
23
+ "@vzn/vx-darwin-arm64": "0.0.102"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env -S bun --no-env-file
2
2
  import { registerCoreAlias, run } from './cli/index.js'
3
- import { fsRefusalHint, isFsRefusal, isUserError } from './util/index.js'
3
+ import {
4
+ fsRefusalHint,
5
+ isFsRefusal,
6
+ isOutOfFds,
7
+ isUserError,
8
+ OUT_OF_FDS_HINT,
9
+ } from './util/index.js'
4
10
 
5
11
  // Every `import … from '@vzn/vx'` this process evaluates — a plugin
6
12
  // package, a workspace or project config — resolves to THIS core, not to
@@ -38,6 +44,8 @@ async function main(): Promise<void> {
38
44
  // it produced `vx: vx why: …` (walkthrough, 2026-09-04).
39
45
  const m = err.message
40
46
  process.stderr.write(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
47
+ } else if (isOutOfFds(err)) {
48
+ process.stderr.write(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
41
49
  } else if (isFsRefusal(err)) {
42
50
  // The file system's refusal names the path; the stack would name
43
51
  // the verb's write, which the reader cannot act on either.
@@ -25,6 +25,7 @@ import {
25
25
  isExecutableMissing,
26
26
  isLiteralPattern,
27
27
  normalizeGlob,
28
+ shellArgv,
28
29
  slashBraceExpansions,
29
30
  taskGlob,
30
31
  UserError,
@@ -382,15 +383,16 @@ async function runRuntimeCommand(
382
383
  const prefix = binDirs.join(path.delimiter)
383
384
  let proc
384
385
  try {
385
- proc = Bun.spawn(['sh', '-c', command], {
386
+ const argv = process.platform === 'win32' ? shellArgv(command) : ['sh', '-c', command]
387
+ proc = Bun.spawn(argv, {
386
388
  cwd,
387
389
  env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
388
390
  stdin: 'ignore',
389
391
  stdout: 'pipe',
390
392
  stderr: 'pipe',
391
393
  // Its own group, so the probe's whole tree can be taken down with vx
392
- // (`killProbesOnExit`).
393
- detached: true,
394
+ // (`killProbesOnExit`). Windows has no groups.
395
+ detached: process.platform !== 'win32',
394
396
  })
395
397
  } catch (err) {
396
398
  // The probe runs through `sh -c` like a task: a box without sh names
package/src/cli/cache.ts CHANGED
@@ -74,7 +74,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
74
74
  return { error: `--older-than requires a value (e.g. 30d, 24h, 60m)` }
75
75
  }
76
76
  const ms = parseDuration(v)
77
- if (ms === null) return { error: `invalid duration: ${v}` }
77
+ if (ms === null) return { error: `invalid duration: ${v} (e.g. 30d, 24h, 60m)` }
78
78
  // `0d` evicts everything ever cached. That is far more often a
79
79
  // computed-to-zero retention than an intent, and no other flag
80
80
  // combination expresses "wipe the cache" — so refuse it and name
@@ -91,7 +91,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
91
91
  return { error: `--max-size requires a value (e.g. 500M, 1G)` }
92
92
  }
93
93
  const bytes = parseSize(v)
94
- if (bytes === null) return { error: `invalid size: ${v}` }
94
+ if (bytes === null) return { error: `invalid size: ${v} (e.g. 500M, 1G)` }
95
95
  if (bytes === 0) {
96
96
  return {
97
97
  error: `--max-size 0 would evict every entry — delete the cache directory instead`,
package/src/cli/help.ts CHANGED
@@ -44,7 +44,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
44
44
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
45
45
  ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
46
46
  ' vx lock [--check]',
47
- ' vx init [--dry] [--force] [--mjs]',
47
+ ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
48
48
  ' vx upgrade [tag]',
49
49
  ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
50
50
  ' vx info [--format pretty|json] [--cache-dir <path>]',
@@ -158,6 +158,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
158
158
  ' TODO(vx-migrate) comments.',
159
159
  ' --dry Print the generated files instead of writing them.',
160
160
  ' --force Overwrite existing vx.config.* files.',
161
+ ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
162
+ ' test (executor, cache, telemetry, schedule, admit, commands,',
163
+ ' project, graph, key).',
161
164
  ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
162
165
  '',
163
166
  'Shell completions:',
package/src/cli/index.ts CHANGED
@@ -40,6 +40,23 @@ export async function run(argv: readonly string[]): Promise<number> {
40
40
  // reference, which lists the plugin verbs.
41
41
  const verb = rest[0]
42
42
  const core = verb !== undefined && (CORE_VERBS as readonly string[]).includes(verb)
43
+ if (verb !== undefined && !core) {
44
+ // A name that is no verb here is the typo `vx rnu` is, not a request
45
+ // for the whole reference: it printed 145 lines and no hint.
46
+ const resolved = await resolvePluginCommand(verb)
47
+ const unknown = resolved === null || 'declaredVerbs' in resolved
48
+ if (unknown && MOVED_VERBS[verb] !== undefined) {
49
+ process.stderr.write(`${MOVED_VERBS[verb]}\n`)
50
+ return 1
51
+ }
52
+ if (unknown) {
53
+ const declared = resolved === null ? [] : resolved.declaredVerbs
54
+ process.stderr.write(
55
+ `vx help: unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)\n`,
56
+ )
57
+ return 1
58
+ }
59
+ }
43
60
  printHelp(await pluginCommandHelp(), core ? verb : undefined)
44
61
  return 0
45
62
  }
package/src/cli/init.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  // per package from its package.json scripts, and the workspace file every
3
3
  // run needs. Turbo and Nx are not read here; a runner's own config beside
4
4
  // the scripts is the richer source, and `@vzn/vx-migrate` maps it.
5
+ // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
6
+ // test instead (plugin-templates.ts, the examples the gate runs).
5
7
 
8
+ import { mkdir } from 'node:fs/promises'
6
9
  import path from 'node:path'
7
10
  import { flagHint, seeHelp } from './help.js'
11
+ import { PLUGIN_TEMPLATES } from './plugin-templates.js'
8
12
  import { isUserError, UserError } from '../util/index.js'
9
13
  import {
10
14
  applyMigration,
@@ -20,18 +24,31 @@ export interface InitArgs {
20
24
  force: boolean
21
25
  /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
22
26
  mjs: boolean
27
+ /** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
28
+ plugin?: string
23
29
  error?: string
24
30
  }
25
31
 
26
32
  export function parseInitArgs(args: readonly string[]): InitArgs {
27
33
  const out: InitArgs = { dry: false, force: false, mjs: false }
28
- for (const a of args) {
29
- if (a === '--dry') out.dry = true
34
+ for (let i = 0; i < args.length; i++) {
35
+ const a = args[i]!
36
+ if (a === '--plugin' || a.startsWith('--plugin=')) {
37
+ const seam = a === '--plugin' ? args[++i] : a.slice('--plugin='.length)
38
+ const seams = Object.keys(PLUGIN_TEMPLATES).join(', ')
39
+ if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
40
+ return {
41
+ ...out,
42
+ error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
43
+ }
44
+ }
45
+ out.plugin = seam
46
+ } else if (a === '--dry') out.dry = true
30
47
  else if (a === '--force') out.force = true
31
48
  else if (a === '--mjs') out.mjs = true
32
49
  else if (a.startsWith('-'))
33
50
  return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
34
- else return { ...out, error: `unexpected argument: ${a}` }
51
+ else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
35
52
  }
36
53
  return out
37
54
  }
@@ -42,6 +59,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
42
59
  process.stderr.write(`vx init: ${parsed.error}\n`)
43
60
  return 1
44
61
  }
62
+ if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
45
63
  const reads: LoadReads = new Map()
46
64
  // "a workspace from nowhere" still needs a package.json to start from,
47
65
  // and the lookup's own refusal named no next step.
@@ -96,3 +114,32 @@ export async function initCmd(args: readonly string[]): Promise<number> {
96
114
  format: parsed.mjs ? 'mjs' : 'ts',
97
115
  })
98
116
  }
117
+
118
+ /** Write `plugins/<seam>.ts` and its test under the workspace root (the cwd when there is none). */
119
+ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
120
+ const root = await findWorkspaceRoot(process.cwd(), new Map()).catch(() => process.cwd())
121
+ const template = PLUGIN_TEMPLATES[seam]!
122
+ const files = [
123
+ [path.join('plugins', `${seam}.ts`), template.plugin],
124
+ [path.join('plugins', `${seam}.test.ts`), template.test],
125
+ ] as const
126
+ if (!args.force) {
127
+ for (const [rel] of files) {
128
+ if (await Bun.file(path.join(root, rel)).exists()) {
129
+ process.stderr.write(`vx init: ${rel} exists; --force overwrites it\n`)
130
+ return 1
131
+ }
132
+ }
133
+ }
134
+ if (!args.dry) {
135
+ await mkdir(path.join(root, 'plugins'), { recursive: true })
136
+ for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
137
+ }
138
+ const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
139
+ process.stdout.write(
140
+ `${args.dry ? 'would write' : 'wrote'} ${files.map(([rel]) => rel).join(' and ')}\n` +
141
+ `Declare it in vx.workspace.ts: import { ${factory} } from './plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
142
+ `Test it: bun test plugins/${seam}.test.ts (needs @vzn/vx installed)\n`,
143
+ )
144
+ return 0
145
+ }
package/src/cli/last.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { formatElapsed, UserError } from '../util/index.js'
18
18
  import { findWorkspaceRoot } from '../workspace/index.js'
19
19
  import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
20
- import { resolveRunId } from './run-id.js'
20
+ import { resolveRunId, shortRunId } from './run-id.js'
21
21
 
22
22
  interface LastArgs {
23
23
  runId?: string
@@ -51,7 +51,13 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
51
51
  if (a === '--format' || a.startsWith('--format=')) {
52
52
  const fv = a === '--format' ? args[++i] : a.slice(9)
53
53
  if (fv !== 'pretty' && fv !== 'json') {
54
- return { ...out, error: `invalid --format: ${fv ?? ''} (expected pretty | json)` }
54
+ return {
55
+ ...out,
56
+ error:
57
+ fv === undefined || fv === ''
58
+ ? '--format requires a value (pretty | json)'
59
+ : `invalid --format: ${fv} (expected pretty | json)`,
60
+ }
55
61
  }
56
62
  out.format = fv
57
63
  continue
@@ -65,7 +71,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
65
71
  }
66
72
  if (a.startsWith('-'))
67
73
  return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
68
- if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
74
+ if (out.runId !== undefined)
75
+ return { ...out, error: `unexpected argument: ${a}${seeHelp('last')}` }
69
76
  out.runId = a
70
77
  }
71
78
  // One run's replay or a list of runs, not both: the id was dropped.
@@ -195,7 +202,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
195
202
  for (const inv of invocations) {
196
203
  const verdict = inv.exitOk ? 'ok ' : 'FAILED'
197
204
  process.stdout.write(
198
- `${verdict} ${fmtWhen(inv.startedAt)} ${inv.runId} ` +
205
+ `${verdict} ${fmtWhen(inv.startedAt)} ${shortRunId(db, inv.runId)} ` +
199
206
  `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
200
207
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
201
208
  )
@@ -236,6 +243,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
236
243
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
237
244
  )
238
245
  lines.push(...formatTaskRows(detail?.tasks ?? []))
246
+ // The next command after reading a failed run, with the arguments the
247
+ // run forwarded (a failure under `-- --shard 2` is that shard's).
248
+ const failed = (detail?.tasks ?? []).filter((t) => t.status === 'failed')
249
+ if (failed.length > 0) {
250
+ const at = inv.command.indexOf(' -- ')
251
+ const forwarded = at === -1 ? '' : inv.command.slice(at)
252
+ const ids = failed.map((t) => `${t.project}#${t.task}`).join(' ')
253
+ lines.push('', ` re-run what failed: vx run ${ids}${forwarded}`)
254
+ }
239
255
  process.stdout.write(`${lines.join('\n')}\n`)
240
256
  return 0
241
257
  } finally {
@@ -0,0 +1,51 @@
1
+ // Generated from packages/vx-plugin-examples/plugins by
2
+ // tests/plugin-templates.unsafe.test.ts; edit the examples, then
3
+ // VX_UPDATE_CONTRACT=1 that test. `vx init --plugin <seam>` writes these.
4
+
5
+ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: string }>> = {
6
+ admit: {
7
+ plugin:
8
+ "// `admit`: whether a ready task starts now, beside what runs here. At most\n// one task of the named `task` runs at a time (say, one that holds a port).\n// Synchronous and cheap: it is asked at every dispatch.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function oneAtATime(task: string): VxPlugin {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n return definePlugin(import.meta, {\n admit(node, ctx) {\n return !named(node.id) || !ctx.running.some((r) => named(r.id))\n },\n })\n}\n",
9
+ test: "// A test for the example `admit` 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-admit-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `oneAtATime()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'admit.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { oneAtATime } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n",
10
+ },
11
+ cache: {
12
+ plugin:
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir, rename } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, 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 renamed: a reader never sees half an artifact.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n await rename(tmp, path.join(this.endpoint, hash))\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('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 cache: { inputs: { files: ['package.json'] }, 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'), { 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
+ },
16
+ commands: {
17
+ plugin:
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 = { workspaceRoot: '/ws', cacheDir: '/ws/.vx/cache', warn() {}, concurrency: 1 }\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
+ },
21
+ executor: {
22
+ plugin:
23
+ "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n const child = Bun.spawn(['sh', '-c', [req.command, ...req.forwardArgs].join(' ')], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
+ test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n",
25
+ },
26
+ graph: {
27
+ plugin:
28
+ "// `graph`: the run's edges. Every task named `task` waits for the one\n// before it in id order, so they run one after another across projects.\n// Core checks the edited graph again (a missing dep, a cycle).\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function chain(task: string): VxPlugin {\n return definePlugin(import.meta, {\n graph(nodes) {\n const ids = [...nodes.keys()].filter((id) => id.endsWith(`#${task}`)).sort()\n for (const [i, id] of ids.entries()) {\n const prev = ids[i - 1]\n const node = nodes.get(id)!\n if (prev !== undefined && !node.deps.includes(prev)) node.deps.push(prev)\n }\n },\n })\n}\n",
29
+ test: "// A test for the example `graph` 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-graph-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `chain()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'graph.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { chain } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n",
30
+ },
31
+ key: {
32
+ plugin:
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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"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 cache: { inputs: { files: ['package.json'] }, 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
+ },
36
+ project: {
37
+ plugin:
38
+ "// `project`: a project's tasks. Every package.json script becomes a task of\n// the same name that runs it, unless the project already declares one.\n// Core re-validates each edit as it would the user's own config.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n project(config, ctx) {\n const scripts = ctx.packageJson['scripts']\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || config.tasks[name] !== undefined) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
39
+ test: "// A test for the example `project` 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-project-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `scriptTasks()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'project.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { scriptTasks } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n",
40
+ },
41
+ schedule: {
42
+ plugin:
43
+ "// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
44
+ test: "// A test for the example `schedule` 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-schedule-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
45
+ },
46
+ telemetry: {
47
+ plugin:
48
+ "// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry() {\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(file, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
49
+ test: "// A test for the example `telemetry` 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-telemetry-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n",
50
+ },
51
+ }
package/src/cli/run-id.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  // A run id as a user types it: the whole id, or a prefix of one, as git
2
- // takes a short sha. `vx last --list` prints 36-character ids; nobody
3
- // retypes one whole.
2
+ // takes a short sha, and as `vx last --list` prints it.
4
3
 
5
4
  import type { Database } from 'bun:sqlite'
6
5
  import { UserError } from '../util/index.js'
@@ -25,3 +24,25 @@ export function resolveRunId(db: Database, raw: string, verb: string): string |
25
24
  `${verb}: run id ${raw} is the start of ${rows.length > 5 ? 'more than 5' : rows.length} runs — type more of it:\n${shown.join('\n')}`,
26
25
  )
27
26
  }
27
+
28
+ /**
29
+ * The shortest prefix of `id` no other recorded run shares, and never
30
+ * shorter than 13 characters: a UUIDv7's first 13 are its millisecond
31
+ * clock, so a shorter one is part of a timestamp. Two indexed lookups, the
32
+ * ids sorted either side of it, since the longest prefix any id shares
33
+ * with it is shared with one of those two.
34
+ */
35
+ export function shortRunId(db: Database, id: string): string {
36
+ const next = db.query('SELECT run_id FROM invocations WHERE run_id > ? ORDER BY run_id LIMIT 1')
37
+ const prev = db.query(
38
+ 'SELECT run_id FROM invocations WHERE run_id < ? ORDER BY run_id DESC LIMIT 1',
39
+ )
40
+ let shared = 0
41
+ for (const row of [next.get(id), prev.get(id)] as Array<{ run_id: string } | null>) {
42
+ if (row === null) continue
43
+ let n = 0
44
+ while (n < id.length && id[n] === row.run_id[n]) n++
45
+ shared = Math.max(shared, n)
46
+ }
47
+ return id.slice(0, Math.max(13, shared + 1))
48
+ }