@vzn/vx 0.0.101 → 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 +46 -15
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/index.ts +17 -0
- package/src/cli/last.ts +18 -3
- package/src/cli/run-id.ts +23 -2
- package/src/cli/show.ts +46 -5
- package/src/cli/watch.ts +14 -1
- package/src/cli/why.ts +19 -3
- 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/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 +113 -5
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
|
|
|
@@ -146,25 +143,59 @@ 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
|
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/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/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
|
|
@@ -196,7 +202,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
196
202
|
for (const inv of invocations) {
|
|
197
203
|
const verdict = inv.exitOk ? 'ok ' : 'FAILED'
|
|
198
204
|
process.stdout.write(
|
|
199
|
-
`${verdict} ${fmtWhen(inv.startedAt)} ${inv.runId} ` +
|
|
205
|
+
`${verdict} ${fmtWhen(inv.startedAt)} ${shortRunId(db, inv.runId)} ` +
|
|
200
206
|
`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
|
|
201
207
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
|
|
202
208
|
)
|
|
@@ -237,6 +243,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
237
243
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
238
244
|
)
|
|
239
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
|
+
}
|
|
240
255
|
process.stdout.write(`${lines.join('\n')}\n`)
|
|
241
256
|
return 0
|
|
242
257
|
} finally {
|
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
|
+
}
|
package/src/cli/show.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
10
10
|
import { declaredTask } from '../graph/index.js'
|
|
11
11
|
import { flagHint, seeHelp } from './help.js'
|
|
12
|
-
import { nearMatches, relPosix, UserError } from '../util/index.js'
|
|
12
|
+
import { nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
|
|
13
13
|
import { loadCliProjects } from './workspace-config.js'
|
|
14
14
|
import {
|
|
15
15
|
findWorkspaceRoot,
|
|
@@ -153,6 +153,45 @@ function projectDir(root: string, meta: ProjectMeta): string {
|
|
|
153
153
|
return rel === '' ? '.' : rel
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A task as `vx show` prints it: a secret-named variable's value masked
|
|
158
|
+
* wherever it appears, in `env.define` and in the command a TS config
|
|
159
|
+
* built from `process.env` (L-11).
|
|
160
|
+
*/
|
|
161
|
+
function shownTask(task: TaskConfig): TaskConfig {
|
|
162
|
+
const exec = task.exec
|
|
163
|
+
if (exec === undefined) return task
|
|
164
|
+
const define = exec.env?.define
|
|
165
|
+
const secrets = secretMask(process.env, define)
|
|
166
|
+
if (secrets === null) return task
|
|
167
|
+
return {
|
|
168
|
+
...task,
|
|
169
|
+
exec: {
|
|
170
|
+
...exec,
|
|
171
|
+
...(exec.command !== undefined ? { command: secrets.mask(exec.command) } : {}),
|
|
172
|
+
...(define !== undefined
|
|
173
|
+
? {
|
|
174
|
+
env: {
|
|
175
|
+
...exec.env,
|
|
176
|
+
define: Object.fromEntries(
|
|
177
|
+
Object.entries(define).map(([k, v]) => [k, secrets.mask(v)]),
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A project config with each task as `shownTask` prints it. */
|
|
187
|
+
function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
|
|
188
|
+
if (config?.tasks === undefined) return config
|
|
189
|
+
return {
|
|
190
|
+
...config,
|
|
191
|
+
tasks: Object.fromEntries(Object.entries(config.tasks).map(([n, t]) => [n, shownTask(t)])),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
156
195
|
function renderList(
|
|
157
196
|
root: string,
|
|
158
197
|
metas: readonly ProjectMeta[],
|
|
@@ -198,7 +237,8 @@ function renderProject(
|
|
|
198
237
|
if (format === 'json') {
|
|
199
238
|
// Round-trip so the printed object is exactly the JSON form of the
|
|
200
239
|
// resolved config (drops `undefined` fields).
|
|
201
|
-
|
|
240
|
+
const shown = shownConfig(config)
|
|
241
|
+
return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
|
|
202
242
|
}
|
|
203
243
|
const head = `${name} — ${dir}`
|
|
204
244
|
const tasks = Object.entries(config?.tasks ?? {})
|
|
@@ -216,7 +256,7 @@ function renderTask(
|
|
|
216
256
|
format: 'pretty' | 'json',
|
|
217
257
|
): string {
|
|
218
258
|
if (format === 'json') {
|
|
219
|
-
const obj = { name, dir, task: taskName, config: task }
|
|
259
|
+
const obj = { name, dir, task: taskName, config: shownTask(task) }
|
|
220
260
|
return `${JSON.stringify(JSON.parse(JSON.stringify(obj)), null, 2)}\n`
|
|
221
261
|
}
|
|
222
262
|
return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
|
|
@@ -233,7 +273,7 @@ function renderTaskAcross(
|
|
|
233
273
|
name: p.name,
|
|
234
274
|
dir: relPosix(root, p.dir) || '.',
|
|
235
275
|
task: taskName,
|
|
236
|
-
config: p.config.tasks![taskName],
|
|
276
|
+
config: shownTask(p.config.tasks![taskName]!),
|
|
237
277
|
}))
|
|
238
278
|
return `${JSON.stringify(JSON.parse(JSON.stringify(list)), null, 2)}\n`
|
|
239
279
|
}
|
|
@@ -246,7 +286,8 @@ function renderTaskAcross(
|
|
|
246
286
|
}
|
|
247
287
|
|
|
248
288
|
/** Every field the run reads, in the order the schema declares them. */
|
|
249
|
-
function taskBlock(taskName: string,
|
|
289
|
+
function taskBlock(taskName: string, raw: TaskConfig): string {
|
|
290
|
+
const task = shownTask(raw)
|
|
250
291
|
const rows: [string, string][] = []
|
|
251
292
|
const list = (xs: readonly string[] | undefined): string | undefined =>
|
|
252
293
|
xs === undefined ? undefined : xs.join(', ')
|
package/src/cli/watch.ts
CHANGED
|
@@ -595,7 +595,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
595
595
|
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
596
596
|
return handle
|
|
597
597
|
}
|
|
598
|
-
|
|
598
|
+
let armed: ReturnType<typeof armWatcher>
|
|
599
|
+
try {
|
|
600
|
+
armed = armWatcher(dir, recursive, onEvent)
|
|
601
|
+
} catch (err) {
|
|
602
|
+
// The OS's watch limit, not the directory: the loop said "watching"
|
|
603
|
+
// and never fired. The poller needs no watch slot.
|
|
604
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
605
|
+
if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
|
|
606
|
+
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
607
|
+
process.stderr.write(
|
|
608
|
+
`vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
|
|
609
|
+
)
|
|
610
|
+
return handle
|
|
611
|
+
}
|
|
599
612
|
watchers.push(armed.watcher)
|
|
600
613
|
proofs.push(
|
|
601
614
|
armed.ready.then((ok) => {
|
package/src/cli/why.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
61
61
|
// got two different diagnoses depending on how it was typed.
|
|
62
62
|
if (a === '--run' || a.startsWith('--run=')) {
|
|
63
63
|
const rv = a === '--run' ? args[++i] : a.slice(6)
|
|
64
|
-
if (rv === undefined || rv === '')
|
|
64
|
+
if (rv === undefined || rv === '')
|
|
65
|
+
return { ...out, error: '--run requires a run id (`vx last --list` shows them)' }
|
|
65
66
|
out.runId = rv
|
|
66
67
|
continue
|
|
67
68
|
}
|
|
@@ -71,7 +72,10 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
71
72
|
// An omitted value reads as empty, not as the literal word "undefined".
|
|
72
73
|
return {
|
|
73
74
|
...out,
|
|
74
|
-
error:
|
|
75
|
+
error:
|
|
76
|
+
fv === undefined || fv === ''
|
|
77
|
+
? '--format requires a value (pretty | json)'
|
|
78
|
+
: `invalid --format: ${fv} (expected pretty | json)`,
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
out.format = fv
|
|
@@ -240,7 +244,19 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
240
244
|
const kinds = [...new Set(diff.entries.map((e) => e.kind))].filter((k) => k in WHAT_TO_DO)
|
|
241
245
|
if (kinds.length > 0) {
|
|
242
246
|
lines.push('', ' what to do:')
|
|
243
|
-
|
|
247
|
+
// An upstream row names the task to ask next: the command, not a
|
|
248
|
+
// placeholder to fill in from the row above.
|
|
249
|
+
const upstream = diff.entries.filter((e) => e.kind === 'upstream').map((e) => e.name)
|
|
250
|
+
for (const k of kinds) {
|
|
251
|
+
const todo =
|
|
252
|
+
k === 'upstream'
|
|
253
|
+
? WHAT_TO_DO[k]!.replace(
|
|
254
|
+
'`vx why <that task>`',
|
|
255
|
+
upstream.map((n) => `\`vx why ${n}\``).join(', '),
|
|
256
|
+
)
|
|
257
|
+
: WHAT_TO_DO[k]
|
|
258
|
+
lines.push(` ${k.padEnd(kindW)} ${todo}`)
|
|
259
|
+
}
|
|
244
260
|
}
|
|
245
261
|
} else if (why.hashChanged === true) {
|
|
246
262
|
lines.push(` detail ${diff.note}`)
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -78,6 +78,8 @@ const GUARD_SCRIPT = [
|
|
|
78
78
|
function startGuard(): void {
|
|
79
79
|
if (guardFd !== undefined) return
|
|
80
80
|
guardFd = null
|
|
81
|
+
// The guard is an `sh` script over process groups; Windows has neither.
|
|
82
|
+
if (process.platform === 'win32') return
|
|
81
83
|
try {
|
|
82
84
|
const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
|
|
83
85
|
argv0: 'vx-group-guard',
|
package/src/exec/runner.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { constants as osConstants } from 'node:os'
|
|
9
|
-
import {
|
|
9
|
+
import { shellArgv, isExecutableMissing, killGraceMs } from '../util/index.js'
|
|
10
10
|
import {
|
|
11
11
|
closeSignalChannel,
|
|
12
12
|
holdGroups,
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
untilGroupsGone,
|
|
20
20
|
} from './kill-tree.js'
|
|
21
21
|
|
|
22
|
+
const WIN32 = process.platform === 'win32'
|
|
23
|
+
/** `sh` names itself in its errors; `bun exec` on Windows keeps its own. */
|
|
24
|
+
const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
|
|
25
|
+
|
|
22
26
|
export interface RunResult {
|
|
23
27
|
exitCode: number
|
|
24
28
|
durationMs: number
|
|
@@ -204,6 +208,8 @@ const SHELL_BUILTINS = new Set([
|
|
|
204
208
|
* the shell.
|
|
205
209
|
*/
|
|
206
210
|
export function execWrap(command: string): string {
|
|
211
|
+
// Bun's shell, the task shell on Windows, has no `exec`.
|
|
212
|
+
if (WIN32) return command
|
|
207
213
|
const first = execWord(command)
|
|
208
214
|
return first === undefined ? command : `exec ${command}`
|
|
209
215
|
}
|
|
@@ -439,13 +445,11 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
439
445
|
const signalFd = opts.signalChannel === true
|
|
440
446
|
child = spawnGuarded((guard) =>
|
|
441
447
|
Bun.spawn(
|
|
442
|
-
|
|
443
|
-
executablePath('sh'),
|
|
444
|
-
'-c',
|
|
448
|
+
shellArgv(
|
|
445
449
|
(guard === undefined ? '' : guardLine(signalFd ? 4 : 3)) + execWrap(opts.command),
|
|
446
|
-
|
|
450
|
+
),
|
|
447
451
|
{
|
|
448
|
-
|
|
452
|
+
...SH_ARGV0,
|
|
449
453
|
cwd: opts.cwd,
|
|
450
454
|
env: opts.env as Record<string, string>,
|
|
451
455
|
// A pipe vx holds and never writes: stdin stays open while vx
|
|
@@ -464,8 +468,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
464
468
|
],
|
|
465
469
|
// Its own session and process group, so a kill reaches what it
|
|
466
470
|
// forked (kill-tree.ts). stdin is a pipe, so a background group
|
|
467
|
-
// never stops on a terminal read.
|
|
468
|
-
detached
|
|
471
|
+
// never stops on a terminal read. Windows has no groups, and a
|
|
472
|
+
// detached child there has no console.
|
|
473
|
+
detached: !WIN32,
|
|
469
474
|
},
|
|
470
475
|
),
|
|
471
476
|
)
|
|
@@ -666,23 +671,17 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
666
671
|
let proc: ReturnType<typeof Bun.spawn>
|
|
667
672
|
try {
|
|
668
673
|
proc = spawnGuarded((guard) =>
|
|
669
|
-
Bun.spawn(
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// Its own session and process group, so a kill reaches what it
|
|
681
|
-
// forked (kill-tree.ts). stdin is ignored, so a background group
|
|
682
|
-
// never stops on a terminal read.
|
|
683
|
-
detached: true,
|
|
684
|
-
},
|
|
685
|
-
),
|
|
674
|
+
Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
|
|
675
|
+
...SH_ARGV0,
|
|
676
|
+
cwd: opts.cwd,
|
|
677
|
+
env: opts.env as Record<string, string>,
|
|
678
|
+
stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
|
|
679
|
+
// Its own session and process group, so a kill reaches what it
|
|
680
|
+
// forked (kill-tree.ts). stdin is ignored, so a background group
|
|
681
|
+
// never stops on a terminal read. Windows has no groups, and a
|
|
682
|
+
// detached child there has no console.
|
|
683
|
+
detached: !WIN32,
|
|
684
|
+
}),
|
|
686
685
|
)
|
|
687
686
|
} catch (err) {
|
|
688
687
|
const stderr = spawnFailureText(err, opts.cwd)
|
|
@@ -157,9 +157,10 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
|
|
|
157
157
|
* produce the SRT customConfig. Path arrays are unioned and deduped; every
|
|
158
158
|
* read grant is punched around the write grants (`punchWritePaths`).
|
|
159
159
|
*
|
|
160
|
-
* Network:
|
|
161
|
-
*
|
|
162
|
-
*
|
|
160
|
+
* Network: the lists are passed as the task declared them, but SRT's
|
|
161
|
+
* proxy filters by the RUN's lists from `initialize()`, never these: a
|
|
162
|
+
* task reaches the run's allowlist union less its deny union
|
|
163
|
+
* (`sandboxRunUnion`, B-21), whatever it declared here.
|
|
163
164
|
*/
|
|
164
165
|
export function buildCustomConfig(
|
|
165
166
|
args: Pick<SandboxedRunArgs, 'config'>,
|
|
@@ -185,9 +186,8 @@ export function buildCustomConfig(
|
|
|
185
186
|
},
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// omitted means no network at all.
|
|
189
|
+
// SRT requires both lists on any network config, so both are supplied;
|
|
190
|
+
// its proxy filters by the run's lists, not these (see above).
|
|
191
191
|
custom.network = {
|
|
192
192
|
allowedDomains: c.network === true ? ['*'] : [...(c.network ?? [])],
|
|
193
193
|
deniedDomains: [...(c.denyNetwork ?? [])],
|