@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 +60 -29
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/init.ts +50 -3
- package/src/cli/last.ts +20 -4
- package/src/cli/plugin-templates.ts +51 -0
- package/src/cli/run-id.ts +23 -2
- package/src/cli/run.ts +22 -7
- package/src/cli/show.ts +48 -6
- package/src/cli/watch-set.ts +182 -0
- package/src/cli/watch.ts +16 -181
- package/src/cli/why.ts +21 -4
- package/src/exec/kill-tree.ts +2 -0
- package/src/exec/runner.ts +24 -25
- package/src/exec/sandbox-binds.ts +6 -6
- package/src/exec/sandbox-runtime.ts +131 -6
- package/src/orchestrator/admission.ts +4 -3
- package/src/orchestrator/events.ts +3 -1
- package/src/orchestrator/execute-task.ts +38 -11
- package/src/orchestrator/framed-output.ts +7 -2
- package/src/orchestrator/hit-restore.ts +7 -2
- package/src/orchestrator/sandbox-request.ts +10 -2
- package/src/orchestrator/telemetry.ts +3 -2
- package/src/util/errors.ts +15 -0
- package/src/util/index.ts +4 -1
- package/src/util/secret-mask.ts +145 -0
- package/src/util/which.ts +12 -0
- package/src/workspace/affected.ts +10 -2
- package/src/workspace/config-cache.ts +3 -1
- package/src/workspace/config-imports.ts +151 -25
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
|
-
|
|
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
|
|
96
|
+
## Already on Turborepo, Nx or moon?
|
|
100
97
|
|
|
101
|
-
Keep your `turbo.json
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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 {
|
|
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.
|
package/src/cache/inputs.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 (
|
|
29
|
-
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
+
}
|