@vzn/vx 0.0.99 → 0.0.101
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 +14 -14
- package/package.json +5 -5
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/init.ts +50 -3
- package/src/cli/last.ts +2 -1
- package/src/cli/plugin-templates.ts +51 -0
- package/src/cli/run.ts +22 -7
- package/src/cli/show.ts +2 -1
- package/src/cli/watch-set.ts +182 -0
- package/src/cli/watch.ts +2 -180
- package/src/cli/why.ts +2 -1
- package/src/exec/sandbox-runtime.ts +18 -2
- package/src/orchestrator/admission.ts +4 -3
- package/src/workspace/config-imports.ts +38 -20
package/README.md
CHANGED
|
@@ -96,15 +96,15 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
|
|
|
96
96
|
[CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
|
|
97
97
|
[playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
|
|
98
98
|
|
|
99
|
-
## Already on Turborepo or
|
|
99
|
+
## Already on Turborepo, Nx or moon?
|
|
100
100
|
|
|
101
|
-
Keep your `turbo.json
|
|
102
|
-
with nothing rewritten:
|
|
101
|
+
Keep your `turbo.json`, Nx project graph or `.moon/`. Two lines run it
|
|
102
|
+
under vx, with nothing rewritten:
|
|
103
103
|
|
|
104
104
|
```ts
|
|
105
105
|
// vx.workspace.ts
|
|
106
106
|
import { defineWorkspace } from '@vzn/vx'
|
|
107
|
-
import { turbo } from '@vzn/vx-migrate' // or: nx
|
|
107
|
+
import { turbo } from '@vzn/vx-migrate' // or: nx, moon
|
|
108
108
|
|
|
109
109
|
export default defineWorkspace({ plugins: [turbo()] })
|
|
110
110
|
```
|
|
@@ -112,7 +112,7 @@ export default defineWorkspace({ plugins: [turbo()] })
|
|
|
112
112
|
When you want configs of your own, one command writes them:
|
|
113
113
|
|
|
114
114
|
```sh
|
|
115
|
-
bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json
|
|
115
|
+
bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json, an Nx graph or .moon/
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
The written configs derive the same cache keys, so what `turbo()` cached
|
|
@@ -170,15 +170,15 @@ when another tool is the better pick:
|
|
|
170
170
|
Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
|
|
171
171
|
machine.
|
|
172
172
|
|
|
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
|
|
173
|
+
| Package | What it adds |
|
|
174
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
175
|
+
| [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo, Nx or moon 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 |
|
|
182
182
|
|
|
183
183
|
Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
|
|
184
184
|
[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.101",
|
|
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.101",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.101",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.101",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.101"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
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/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
|
@@ -65,7 +65,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
65
65
|
}
|
|
66
66
|
if (a.startsWith('-'))
|
|
67
67
|
return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
|
|
68
|
-
if (out.runId !== undefined)
|
|
68
|
+
if (out.runId !== undefined)
|
|
69
|
+
return { ...out, error: `unexpected argument: ${a}${seeHelp('last')}` }
|
|
69
70
|
out.runId = a
|
|
70
71
|
}
|
|
71
72
|
// One run's replay or a list of runs, not both: the id was dropped.
|
|
@@ -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.ts
CHANGED
|
@@ -150,11 +150,19 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
150
150
|
const a = before[i]
|
|
151
151
|
if (a === '--filter' || a?.startsWith('--filter=')) {
|
|
152
152
|
const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
|
|
153
|
-
if (v === undefined || v === '')
|
|
153
|
+
if (v === undefined || v === '')
|
|
154
|
+
return {
|
|
155
|
+
...out,
|
|
156
|
+
error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
|
|
157
|
+
}
|
|
154
158
|
out.filters.push(v)
|
|
155
159
|
} else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
|
|
156
160
|
const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
|
|
157
|
-
if (v === undefined)
|
|
161
|
+
if (v === undefined)
|
|
162
|
+
return {
|
|
163
|
+
...out,
|
|
164
|
+
error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
|
|
165
|
+
}
|
|
158
166
|
const n = parseConcurrency(v)
|
|
159
167
|
if (n === null)
|
|
160
168
|
return {
|
|
@@ -193,7 +201,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
193
201
|
out.frozen = true
|
|
194
202
|
} else if (a === '--retry' || a?.startsWith('--retry=')) {
|
|
195
203
|
const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
|
|
196
|
-
if (v === undefined)
|
|
204
|
+
if (v === undefined)
|
|
205
|
+
return { ...out, error: `--retry requires a value (a non-negative integer)` }
|
|
197
206
|
const n = parseDecimalInt(v)
|
|
198
207
|
if (n === null) {
|
|
199
208
|
return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
|
|
@@ -201,7 +210,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
201
210
|
out.retries = n
|
|
202
211
|
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
203
212
|
const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
|
|
204
|
-
if (v === undefined)
|
|
213
|
+
if (v === undefined)
|
|
214
|
+
return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
|
|
205
215
|
const n = parseDecimalInt(v)
|
|
206
216
|
if (n !== null && n > MAX_TIMEOUT_MS) {
|
|
207
217
|
// Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
|
|
@@ -243,7 +253,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
243
253
|
out.cacheDir = v
|
|
244
254
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
245
255
|
const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
|
|
246
|
-
if (v === undefined)
|
|
256
|
+
if (v === undefined)
|
|
257
|
+
return {
|
|
258
|
+
...out,
|
|
259
|
+
error: `--cache requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
|
|
260
|
+
}
|
|
247
261
|
// A spec with no segment at all applies NOTHING, so `--cache=`
|
|
248
262
|
// (an empty shell var, or someone reaching for "no cache") left
|
|
249
263
|
// all four axes ON — the opposite of the intent. Reject it; an
|
|
@@ -283,7 +297,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
283
297
|
out.continueMode = v
|
|
284
298
|
} else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
|
|
285
299
|
const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
|
|
286
|
-
if (v === undefined)
|
|
300
|
+
if (v === undefined)
|
|
301
|
+
return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
|
|
287
302
|
const n = parseDecimalInt(v)
|
|
288
303
|
if (n === null) return { ...out, error: `invalid verbosity: ${v} (a non-negative integer)` }
|
|
289
304
|
out.verbosity = n
|
|
@@ -317,7 +332,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
317
332
|
out.affected = a.slice('--affected='.length)
|
|
318
333
|
} else if (a === '--tag' || a?.startsWith('--tag=')) {
|
|
319
334
|
const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
|
|
320
|
-
if (raw === undefined) return { ...out, error: `${a} requires a value` }
|
|
335
|
+
if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
|
|
321
336
|
// Split on the FIRST `=` so values may contain `=` (e.g. a URL).
|
|
322
337
|
const eq = raw.indexOf('=')
|
|
323
338
|
if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
|
package/src/cli/show.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
35
35
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
36
36
|
else if (a.startsWith('-'))
|
|
37
37
|
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
38
|
-
else if (out.target !== undefined)
|
|
38
|
+
else if (out.target !== undefined)
|
|
39
|
+
return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
|
|
39
40
|
// An empty name is a part of every name: its "did you mean" listed the
|
|
40
41
|
// whole workspace.
|
|
41
42
|
else if (a === '') return { ...out, error: 'empty target (omit it to list every project)' }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What `vx watch` watches: the projects a cycle can run, what their configs
|
|
2
|
+
// declare (outputs to ignore, inputs, workspace-wide globs) and the member
|
|
3
|
+
// directories a package glob can grow. Read at start and again whenever an
|
|
4
|
+
// edit reshapes the set; the loop in watch.ts decides when.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import type { ProjectConfig } from '../config.js'
|
|
9
|
+
import {
|
|
10
|
+
buildPackageGraph,
|
|
11
|
+
configImports,
|
|
12
|
+
loadProjectConfig,
|
|
13
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
14
|
+
type ProjectEntry,
|
|
15
|
+
type ProjectMeta,
|
|
16
|
+
} from '../workspace/index.js'
|
|
17
|
+
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
18
|
+
import { type CliLoadOptions, loadCliProjects } from './workspace-config.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
22
|
+
* the scope plus its transitive dependencies through the package graph
|
|
23
|
+
* and the cross-project `dependsOn` edges the task graph adds
|
|
24
|
+
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
25
|
+
* per-project arm watched the filter's answer only, so
|
|
26
|
+
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
27
|
+
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
28
|
+
* scope is every project already and walks nothing. A config the loader
|
|
29
|
+
* rejects contributes no edges here; the run that just happened said so.
|
|
30
|
+
*/
|
|
31
|
+
export async function watchedProjects(
|
|
32
|
+
workspaceRoot: string,
|
|
33
|
+
allProjects: readonly ProjectMeta[],
|
|
34
|
+
scope: readonly ProjectMeta[],
|
|
35
|
+
load: CliLoadOptions = {},
|
|
36
|
+
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
37
|
+
): Promise<ProjectMeta[]> {
|
|
38
|
+
if (scope.length === allProjects.length) return [...allProjects]
|
|
39
|
+
let edges: Map<string, string[]> | undefined
|
|
40
|
+
try {
|
|
41
|
+
edges =
|
|
42
|
+
staged !== null
|
|
43
|
+
? taskEdgesFrom(staged)
|
|
44
|
+
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
45
|
+
} catch {
|
|
46
|
+
edges = undefined
|
|
47
|
+
}
|
|
48
|
+
const graph = buildPackageGraph([...allProjects], edges)
|
|
49
|
+
const names = new Set(scope.map((p) => p.name))
|
|
50
|
+
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
51
|
+
return allProjects.filter((p) => names.has(p.name))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What one sweep over every config tells the loop. */
|
|
55
|
+
export interface ConfigSweep {
|
|
56
|
+
workspaceWide: boolean
|
|
57
|
+
workspaceInputs: string[]
|
|
58
|
+
outputs: Map<string, string[]>
|
|
59
|
+
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
60
|
+
inputs: Map<string, string[]>
|
|
61
|
+
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
62
|
+
uncached: Set<string>
|
|
63
|
+
/** Files the project configs import by relative path (item 949). */
|
|
64
|
+
configImports: string[]
|
|
65
|
+
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
66
|
+
workspaceConfigImports: string[]
|
|
67
|
+
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
68
|
+
staged: Map<string, ProjectEntry> | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One sweep over every config for the two things the loop needs: whether
|
|
73
|
+
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
74
|
+
* prefix, so the whole tree has to be watched — and every project's
|
|
75
|
+
* declared outputs, so their writes are not taken for edits. The run
|
|
76
|
+
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
77
|
+
* output a plugin gave a config-less package is ignored like a declared
|
|
78
|
+
* one, and a pure config is served from its cached evaluation rather than
|
|
79
|
+
* re-evaluated in a worker. A config that fails to load is out of this
|
|
80
|
+
* concern's scope — the run that just happened already said so, or will —
|
|
81
|
+
* and the sweep falls back to the files that do load, one by one.
|
|
82
|
+
*/
|
|
83
|
+
export async function sweepConfigs(
|
|
84
|
+
projects: readonly ProjectMeta[],
|
|
85
|
+
workspaceRoot: string,
|
|
86
|
+
load: CliLoadOptions = {},
|
|
87
|
+
): Promise<ConfigSweep> {
|
|
88
|
+
const outputs = new Map<string, string[]>()
|
|
89
|
+
const inputs = new Map<string, string[]>()
|
|
90
|
+
const addTo = (
|
|
91
|
+
into: Map<string, string[]>,
|
|
92
|
+
dir: string,
|
|
93
|
+
globs: readonly string[] | undefined,
|
|
94
|
+
): void => {
|
|
95
|
+
if (globs === undefined || globs.length === 0) return
|
|
96
|
+
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
97
|
+
}
|
|
98
|
+
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
99
|
+
addTo(outputs, dir, globs)
|
|
100
|
+
const workspaceInputs = new Set<string>()
|
|
101
|
+
const uncached = new Set<string>()
|
|
102
|
+
const fold = (dir: string, config: ProjectConfig): void => {
|
|
103
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
104
|
+
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
105
|
+
uncached.add(dir)
|
|
106
|
+
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
107
|
+
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
108
|
+
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
109
|
+
add(dir, task.cache?.outputs?.files)
|
|
110
|
+
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|
|
114
|
+
workspaceWide: workspaceInputs.size > 0,
|
|
115
|
+
workspaceInputs: [...workspaceInputs],
|
|
116
|
+
outputs,
|
|
117
|
+
inputs,
|
|
118
|
+
uncached,
|
|
119
|
+
configImports: [...projectImports],
|
|
120
|
+
workspaceConfigImports: [...wsImports],
|
|
121
|
+
staged,
|
|
122
|
+
})
|
|
123
|
+
const projectImports = new Set<string>()
|
|
124
|
+
const wsImports = new Set<string>()
|
|
125
|
+
await Promise.all([
|
|
126
|
+
...projects.map(async (p) => {
|
|
127
|
+
if (p.configPath === null) return
|
|
128
|
+
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
129
|
+
}),
|
|
130
|
+
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
131
|
+
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
132
|
+
}),
|
|
133
|
+
])
|
|
134
|
+
let staged: Map<string, ProjectEntry> | null = null
|
|
135
|
+
try {
|
|
136
|
+
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
137
|
+
} catch {
|
|
138
|
+
staged = null
|
|
139
|
+
}
|
|
140
|
+
if (staged !== null) {
|
|
141
|
+
for (const p of staged.values()) fold(p.dir, p.config)
|
|
142
|
+
return result(staged)
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(
|
|
145
|
+
projects.map(async (p) => {
|
|
146
|
+
if (p.configPath === null) return
|
|
147
|
+
try {
|
|
148
|
+
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
149
|
+
} catch {
|
|
150
|
+
// broken config — out of this concern's scope
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
return result(null)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The directory entries under a package glob's directory that can be
|
|
159
|
+
* members: directories (or links) whose name is not dotted and not
|
|
160
|
+
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
161
|
+
* directory reacts only when this set changes.
|
|
162
|
+
*/
|
|
163
|
+
export function memberEntries(base: string): ReadonlySet<string> {
|
|
164
|
+
const out = new Set<string>()
|
|
165
|
+
let entries: fs.Dirent[]
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
168
|
+
} catch {
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
173
|
+
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
179
|
+
if (a.size !== b.size) return false
|
|
180
|
+
for (const name of a) if (!b.has(name)) return false
|
|
181
|
+
return true
|
|
182
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -25,22 +25,15 @@ import {
|
|
|
25
25
|
type RunOptions,
|
|
26
26
|
} from '../orchestrator/index.js'
|
|
27
27
|
import {
|
|
28
|
-
buildPackageGraph,
|
|
29
28
|
findWorkspaceRoot,
|
|
30
29
|
type LoadReads,
|
|
31
30
|
listProjects,
|
|
32
|
-
loadProjectConfig,
|
|
33
31
|
loadWorkspace,
|
|
34
32
|
LOCKFILE_NAME,
|
|
35
33
|
memberBaseDirs,
|
|
36
|
-
WORKSPACE_CONFIG_FILENAMES,
|
|
37
|
-
configImports,
|
|
38
|
-
type ProjectEntry,
|
|
39
34
|
type ProjectMeta,
|
|
40
35
|
} from '../workspace/index.js'
|
|
41
|
-
import type
|
|
42
|
-
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
43
|
-
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
36
|
+
import { type CliLoadOptions, loadCliWorkspace } from './workspace-config.js'
|
|
44
37
|
import {
|
|
45
38
|
gitIgnored,
|
|
46
39
|
isIgnoredWatchPath,
|
|
@@ -59,6 +52,7 @@ import {
|
|
|
59
52
|
WATCH_PROBE_TIMEOUT_MS,
|
|
60
53
|
type WatchHandle,
|
|
61
54
|
} from './watch-fs.js'
|
|
55
|
+
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
62
56
|
|
|
63
57
|
/** Wait this long after the last filesystem event before re-running. */
|
|
64
58
|
const DEBOUNCE_MS = 150
|
|
@@ -264,151 +258,6 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
264
258
|
})
|
|
265
259
|
}
|
|
266
260
|
|
|
267
|
-
/**
|
|
268
|
-
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
269
|
-
* the scope plus its transitive dependencies through the package graph
|
|
270
|
-
* and the cross-project `dependsOn` edges the task graph adds
|
|
271
|
-
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
272
|
-
* per-project arm watched the filter's answer only, so
|
|
273
|
-
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
274
|
-
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
275
|
-
* scope is every project already and walks nothing. A config the loader
|
|
276
|
-
* rejects contributes no edges here; the run that just happened said so.
|
|
277
|
-
*/
|
|
278
|
-
export async function watchedProjects(
|
|
279
|
-
workspaceRoot: string,
|
|
280
|
-
allProjects: readonly ProjectMeta[],
|
|
281
|
-
scope: readonly ProjectMeta[],
|
|
282
|
-
load: CliLoadOptions = {},
|
|
283
|
-
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
284
|
-
): Promise<ProjectMeta[]> {
|
|
285
|
-
if (scope.length === allProjects.length) return [...allProjects]
|
|
286
|
-
let edges: Map<string, string[]> | undefined
|
|
287
|
-
try {
|
|
288
|
-
edges =
|
|
289
|
-
staged !== null
|
|
290
|
-
? taskEdgesFrom(staged)
|
|
291
|
-
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
292
|
-
} catch {
|
|
293
|
-
edges = undefined
|
|
294
|
-
}
|
|
295
|
-
const graph = buildPackageGraph([...allProjects], edges)
|
|
296
|
-
const names = new Set(scope.map((p) => p.name))
|
|
297
|
-
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
298
|
-
return allProjects.filter((p) => names.has(p.name))
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* One sweep over every config for the two things the loop needs: whether
|
|
303
|
-
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
304
|
-
* prefix, so the whole tree has to be watched — and every project's
|
|
305
|
-
* declared outputs, so their writes are not taken for edits. The run
|
|
306
|
-
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
307
|
-
* output a plugin gave a config-less package is ignored like a declared
|
|
308
|
-
* one, and a pure config is served from its cached evaluation rather than
|
|
309
|
-
* re-evaluated in a worker. A config that fails to load is out of this
|
|
310
|
-
* concern's scope — the run that just happened already said so, or will —
|
|
311
|
-
* and the sweep falls back to the files that do load, one by one.
|
|
312
|
-
*/
|
|
313
|
-
export async function sweepConfigs(
|
|
314
|
-
projects: readonly ProjectMeta[],
|
|
315
|
-
workspaceRoot: string,
|
|
316
|
-
load: CliLoadOptions = {},
|
|
317
|
-
): Promise<{
|
|
318
|
-
workspaceWide: boolean
|
|
319
|
-
workspaceInputs: string[]
|
|
320
|
-
outputs: Map<string, string[]>
|
|
321
|
-
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
322
|
-
inputs: Map<string, string[]>
|
|
323
|
-
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
324
|
-
uncached: Set<string>
|
|
325
|
-
/** Files the project configs import by relative path (item 949). */
|
|
326
|
-
configImports: string[]
|
|
327
|
-
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
328
|
-
workspaceConfigImports: string[]
|
|
329
|
-
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
330
|
-
staged: Map<string, ProjectEntry> | null
|
|
331
|
-
}> {
|
|
332
|
-
const outputs = new Map<string, string[]>()
|
|
333
|
-
const inputs = new Map<string, string[]>()
|
|
334
|
-
const addTo = (
|
|
335
|
-
into: Map<string, string[]>,
|
|
336
|
-
dir: string,
|
|
337
|
-
globs: readonly string[] | undefined,
|
|
338
|
-
): void => {
|
|
339
|
-
if (globs === undefined || globs.length === 0) return
|
|
340
|
-
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
341
|
-
}
|
|
342
|
-
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
343
|
-
addTo(outputs, dir, globs)
|
|
344
|
-
const workspaceInputs = new Set<string>()
|
|
345
|
-
const uncached = new Set<string>()
|
|
346
|
-
const fold = (dir: string, config: ProjectConfig): void => {
|
|
347
|
-
for (const task of Object.values(config.tasks ?? {})) {
|
|
348
|
-
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
349
|
-
uncached.add(dir)
|
|
350
|
-
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
351
|
-
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
352
|
-
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
353
|
-
add(dir, task.cache?.outputs?.files)
|
|
354
|
-
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
const result = (
|
|
358
|
-
staged: Map<string, ProjectEntry> | null,
|
|
359
|
-
): {
|
|
360
|
-
workspaceWide: boolean
|
|
361
|
-
workspaceInputs: string[]
|
|
362
|
-
outputs: Map<string, string[]>
|
|
363
|
-
inputs: Map<string, string[]>
|
|
364
|
-
uncached: Set<string>
|
|
365
|
-
configImports: string[]
|
|
366
|
-
workspaceConfigImports: string[]
|
|
367
|
-
staged: Map<string, ProjectEntry> | null
|
|
368
|
-
} => ({
|
|
369
|
-
workspaceWide: workspaceInputs.size > 0,
|
|
370
|
-
workspaceInputs: [...workspaceInputs],
|
|
371
|
-
outputs,
|
|
372
|
-
inputs,
|
|
373
|
-
uncached,
|
|
374
|
-
configImports: [...projectImports],
|
|
375
|
-
workspaceConfigImports: [...wsImports],
|
|
376
|
-
staged,
|
|
377
|
-
})
|
|
378
|
-
const projectImports = new Set<string>()
|
|
379
|
-
const wsImports = new Set<string>()
|
|
380
|
-
await Promise.all([
|
|
381
|
-
...projects.map(async (p) => {
|
|
382
|
-
if (p.configPath === null) return
|
|
383
|
-
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
384
|
-
}),
|
|
385
|
-
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
386
|
-
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
387
|
-
}),
|
|
388
|
-
])
|
|
389
|
-
let staged: Map<string, ProjectEntry> | null = null
|
|
390
|
-
try {
|
|
391
|
-
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
392
|
-
} catch {
|
|
393
|
-
staged = null
|
|
394
|
-
}
|
|
395
|
-
if (staged !== null) {
|
|
396
|
-
for (const p of staged.values()) fold(p.dir, p.config)
|
|
397
|
-
return result(staged)
|
|
398
|
-
}
|
|
399
|
-
await Promise.all(
|
|
400
|
-
projects.map(async (p) => {
|
|
401
|
-
if (p.configPath === null) return
|
|
402
|
-
try {
|
|
403
|
-
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
404
|
-
} catch {
|
|
405
|
-
// broken config — out of this concern's scope
|
|
406
|
-
}
|
|
407
|
-
}),
|
|
408
|
-
)
|
|
409
|
-
return result(null)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
261
|
interface WatchLoopArgs {
|
|
413
262
|
opts: RunOptions
|
|
414
263
|
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
@@ -1099,30 +948,3 @@ export function pendingAfterCycle(
|
|
|
1099
948
|
const [abs, label] = [...pending][0]!
|
|
1100
949
|
return [abs, label]
|
|
1101
950
|
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* The directory entries under a package glob's directory that can be
|
|
1105
|
-
* members: directories (or links) whose name is not dotted and not
|
|
1106
|
-
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
1107
|
-
* directory reacts only when this set changes.
|
|
1108
|
-
*/
|
|
1109
|
-
export function memberEntries(base: string): ReadonlySet<string> {
|
|
1110
|
-
const out = new Set<string>()
|
|
1111
|
-
let entries: fs.Dirent[]
|
|
1112
|
-
try {
|
|
1113
|
-
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
1114
|
-
} catch {
|
|
1115
|
-
return out
|
|
1116
|
-
}
|
|
1117
|
-
for (const e of entries) {
|
|
1118
|
-
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
1119
|
-
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
1120
|
-
}
|
|
1121
|
-
return out
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
1125
|
-
if (a.size !== b.size) return false
|
|
1126
|
-
for (const name of a) if (!b.has(name)) return false
|
|
1127
|
-
return true
|
|
1128
|
-
}
|
package/src/cli/why.ts
CHANGED
|
@@ -86,7 +86,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
86
86
|
}
|
|
87
87
|
if (a.startsWith('-'))
|
|
88
88
|
return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
|
|
89
|
-
if (out.target !== undefined)
|
|
89
|
+
if (out.target !== undefined)
|
|
90
|
+
return { ...out, error: `unexpected argument: ${a}${seeHelp('why')}` }
|
|
90
91
|
// Empty is no task: read as a query it matched every recorded one.
|
|
91
92
|
if (a !== '') out.target = a
|
|
92
93
|
}
|
|
@@ -340,7 +340,18 @@ function unlinkOnExit(file: string): void {
|
|
|
340
340
|
* that names it outright still reaches it.
|
|
341
341
|
*/
|
|
342
342
|
function taskTmpdir(tag: string): string {
|
|
343
|
-
return path.join(
|
|
343
|
+
return path.join(taskTmpRoot(), `vx-task-${process.pid}-${tag}`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The parent of every task's own temp directory, walled off from every
|
|
348
|
+
* sandboxed task, each granted its own directory inside: SRT binds its
|
|
349
|
+
* whole temp dir writable into every task, so a task listed and read what
|
|
350
|
+
* a concurrent one kept in its TMPDIR, or replaced its port bridge's
|
|
351
|
+
* socket (L-10).
|
|
352
|
+
*/
|
|
353
|
+
function taskTmpRoot(): string {
|
|
354
|
+
return path.join(sandboxTmpdir(), 'vx-tasks')
|
|
344
355
|
}
|
|
345
356
|
|
|
346
357
|
const liveTaskTmpdirs = new Set<string>()
|
|
@@ -799,6 +810,11 @@ export async function wrapSandboxedCommand(
|
|
|
799
810
|
|
|
800
811
|
const baselines = canonicalBaselines(args)
|
|
801
812
|
const customConfig = buildCustomConfig(args, baselines)
|
|
813
|
+
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
814
|
+
customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
|
|
815
|
+
// Seatbelt re-allows a read inside a denied region only by name; on Linux
|
|
816
|
+
// a read grant over a write path would remount it read-only.
|
|
817
|
+
if (process.platform === 'darwin') customConfig!.filesystem!.allowRead!.push(toRealPath(tmp))
|
|
802
818
|
// Linux: the ports a list grants are bridged out of the task's network
|
|
803
819
|
// namespace. The task's side of each bridge is a socat in front of the
|
|
804
820
|
// user command, so it goes INTO the sandboxed command; the host side is
|
|
@@ -972,7 +988,7 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
972
988
|
|
|
973
989
|
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
974
990
|
export function portBridgeSocket(tag: string, port: number): string {
|
|
975
|
-
return path.join(
|
|
991
|
+
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
976
992
|
}
|
|
977
993
|
|
|
978
994
|
/**
|
|
@@ -64,8 +64,9 @@ export function taintTracker(
|
|
|
64
64
|
u !== undefined && (failed(u) || taintOf(u.node.id))
|
|
65
65
|
// Post-order over the settled deps with an explicit stack: a chain of
|
|
66
66
|
// restore-tier hits is judged in one ask, and a recursion would take a
|
|
67
|
-
// frame per hop.
|
|
68
|
-
// answer
|
|
67
|
+
// frame per hop. Taint is final at once; a clean answer only once every
|
|
68
|
+
// dep has settled and its own answer is kept, since a dep below a settled
|
|
69
|
+
// one may still fail (C-23).
|
|
69
70
|
const taintOf = (root: string): boolean => {
|
|
70
71
|
const known = memo.get(root)
|
|
71
72
|
if (known !== undefined) return known
|
|
@@ -95,7 +96,7 @@ export function taintTracker(
|
|
|
95
96
|
return u !== undefined && (failed(u) || valueOf(d) === true)
|
|
96
97
|
})
|
|
97
98
|
local.set(id, taint)
|
|
98
|
-
if (taint || deps.every((d) => outcomes.has(d))) memo.set(id, taint)
|
|
99
|
+
if (taint || deps.every((d) => outcomes.has(d) && memo.has(d))) memo.set(id, taint)
|
|
99
100
|
}
|
|
100
101
|
return valueOf(root)!
|
|
101
102
|
}
|
|
@@ -135,6 +135,14 @@ export function unprovidedBareImports(
|
|
|
135
135
|
return out
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A textual pass before the scan: a relative specifier opens with a quoted
|
|
140
|
+
* `./` or `../`, or spells one with an escape, and most configs hold
|
|
141
|
+
* neither, so the scan is skipped for them (D-23). A candidate in a comment
|
|
142
|
+
* or string still goes to the scan, which decides.
|
|
143
|
+
*/
|
|
144
|
+
const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
|
|
145
|
+
|
|
138
146
|
/**
|
|
139
147
|
* Absolute resolved targets of the RELATIVE specifiers in `source`; one that
|
|
140
148
|
* does not resolve contributes the paths it names.
|
|
@@ -145,6 +153,7 @@ export function unprovidedBareImports(
|
|
|
145
153
|
* which is correct: an erased import cannot move a resolved value.
|
|
146
154
|
*/
|
|
147
155
|
function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
|
|
156
|
+
if (!RELATIVE_CANDIDATE.test(source)) return []
|
|
148
157
|
let specifiers: string[]
|
|
149
158
|
try {
|
|
150
159
|
specifiers = scanner(loader)
|
|
@@ -246,26 +255,35 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
246
255
|
// changed set answers every root at once, instead of a walk per root.
|
|
247
256
|
const importedBy = new Map<string, string[]>()
|
|
248
257
|
const visited = new Set<string>()
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
258
|
+
// A level at a time, its files read together: one awaited read per
|
|
259
|
+
// config put 5,000 of them 200 ms behind (D-23).
|
|
260
|
+
let frontier = [...roots.keys()]
|
|
261
|
+
while (frontier.length > 0) {
|
|
262
|
+
const read = await Promise.all(
|
|
263
|
+
frontier.map(async (file) => {
|
|
264
|
+
if (visited.has(file)) return null
|
|
265
|
+
visited.add(file)
|
|
266
|
+
try {
|
|
267
|
+
return { file, source: await Bun.file(file).text() }
|
|
268
|
+
} catch {
|
|
269
|
+
return null // unreadable: no edges, and not this pass's problem to report
|
|
270
|
+
}
|
|
271
|
+
}),
|
|
272
|
+
)
|
|
273
|
+
frontier = []
|
|
274
|
+
for (const entry of read) {
|
|
275
|
+
if (entry === null) continue
|
|
276
|
+
const { file, source } = entry
|
|
277
|
+
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
278
|
+
for (const target of scanLocalImports(source, path.dirname(file), loader)) {
|
|
279
|
+
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
280
|
+
if (target.split(path.sep).includes('node_modules')) continue
|
|
281
|
+
const list = importedBy.get(target)
|
|
282
|
+
if (list) list.push(file)
|
|
283
|
+
else importedBy.set(target, [file])
|
|
284
|
+
// Descend ONLY through unowned files — see the header.
|
|
285
|
+
if (ownerOf(target, dirToName) === undefined) frontier.push(target)
|
|
286
|
+
}
|
|
269
287
|
}
|
|
270
288
|
}
|
|
271
289
|
|