@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 CHANGED
@@ -38,10 +38,7 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
38
38
  fully cached run replays the graph in 510 ms.
39
39
  <!-- bench:end -->
40
40
 
41
- On a real Turbo repo (`solidjs/solid`, its own `turbo.json`, nothing
42
- rewritten) vx replays a cached build in 51 ms where Turbo takes 95,
43
- restores every output in 66 ms against 127, and builds cold in 40.6 s
44
- against 45.5 s ([benchmarks](packages/vx/docs/benchmarks.md)).
41
+ Measured on eleven real Turborepo and Nx repos too: [tried on real repos](#tried-on-real-repos).
45
42
 
46
43
  ## Install
47
44
 
@@ -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.101",
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.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"
20
+ "@vzn/vx-linux-x64": "0.0.102",
21
+ "@vzn/vx-linux-arm64": "0.0.102",
22
+ "@vzn/vx-darwin-x64": "0.0.102",
23
+ "@vzn/vx-darwin-arm64": "0.0.102"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env -S bun --no-env-file
2
2
  import { registerCoreAlias, run } from './cli/index.js'
3
- import { fsRefusalHint, isFsRefusal, isUserError } from './util/index.js'
3
+ import {
4
+ fsRefusalHint,
5
+ isFsRefusal,
6
+ isOutOfFds,
7
+ isUserError,
8
+ OUT_OF_FDS_HINT,
9
+ } from './util/index.js'
4
10
 
5
11
  // Every `import … from '@vzn/vx'` this process evaluates — a plugin
6
12
  // package, a workspace or project config — resolves to THIS core, not to
@@ -38,6 +44,8 @@ async function main(): Promise<void> {
38
44
  // it produced `vx: vx why: …` (walkthrough, 2026-09-04).
39
45
  const m = err.message
40
46
  process.stderr.write(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
47
+ } else if (isOutOfFds(err)) {
48
+ process.stderr.write(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
41
49
  } else if (isFsRefusal(err)) {
42
50
  // The file system's refusal names the path; the stack would name
43
51
  // the verb's write, which the reader cannot act on either.
@@ -25,6 +25,7 @@ import {
25
25
  isExecutableMissing,
26
26
  isLiteralPattern,
27
27
  normalizeGlob,
28
+ shellArgv,
28
29
  slashBraceExpansions,
29
30
  taskGlob,
30
31
  UserError,
@@ -382,15 +383,16 @@ async function runRuntimeCommand(
382
383
  const prefix = binDirs.join(path.delimiter)
383
384
  let proc
384
385
  try {
385
- proc = Bun.spawn(['sh', '-c', command], {
386
+ const argv = process.platform === 'win32' ? shellArgv(command) : ['sh', '-c', command]
387
+ proc = Bun.spawn(argv, {
386
388
  cwd,
387
389
  env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
388
390
  stdin: 'ignore',
389
391
  stdout: 'pipe',
390
392
  stderr: 'pipe',
391
393
  // Its own group, so the probe's whole tree can be taken down with vx
392
- // (`killProbesOnExit`).
393
- detached: true,
394
+ // (`killProbesOnExit`). Windows has no groups.
395
+ detached: process.platform !== 'win32',
394
396
  })
395
397
  } catch (err) {
396
398
  // The probe runs through `sh -c` like a task: a box without sh names
package/src/cli/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 { ...out, error: `invalid --format: ${fv ?? ''} (expected pretty | json)` }
54
+ return {
55
+ ...out,
56
+ error:
57
+ fv === undefined || fv === ''
58
+ ? '--format requires a value (pretty | json)'
59
+ : `invalid --format: ${fv} (expected pretty | json)`,
60
+ }
55
61
  }
56
62
  out.format = fv
57
63
  continue
@@ -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. `vx last --list` prints 36-character ids; nobody
3
- // retypes one whole.
2
+ // takes a short sha, and as `vx last --list` prints it.
4
3
 
5
4
  import type { Database } from 'bun:sqlite'
6
5
  import { UserError } from '../util/index.js'
@@ -25,3 +24,25 @@ export function resolveRunId(db: Database, raw: string, verb: string): string |
25
24
  `${verb}: run id ${raw} is the start of ${rows.length > 5 ? 'more than 5' : rows.length} runs — type more of it:\n${shown.join('\n')}`,
26
25
  )
27
26
  }
27
+
28
+ /**
29
+ * The shortest prefix of `id` no other recorded run shares, and never
30
+ * shorter than 13 characters: a UUIDv7's first 13 are its millisecond
31
+ * clock, so a shorter one is part of a timestamp. Two indexed lookups, the
32
+ * ids sorted either side of it, since the longest prefix any id shares
33
+ * with it is shared with one of those two.
34
+ */
35
+ export function shortRunId(db: Database, id: string): string {
36
+ const next = db.query('SELECT run_id FROM invocations WHERE run_id > ? ORDER BY run_id LIMIT 1')
37
+ const prev = db.query(
38
+ 'SELECT run_id FROM invocations WHERE run_id < ? ORDER BY run_id DESC LIMIT 1',
39
+ )
40
+ let shared = 0
41
+ for (const row of [next.get(id), prev.get(id)] as Array<{ run_id: string } | null>) {
42
+ if (row === null) continue
43
+ let n = 0
44
+ while (n < id.length && id[n] === row.run_id[n]) n++
45
+ shared = Math.max(shared, n)
46
+ }
47
+ return id.slice(0, Math.max(13, shared + 1))
48
+ }
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
- return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config })), null, 2)}\n`
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, task: TaskConfig): 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
- const armed = armWatcher(dir, recursive, onEvent)
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 === '') return { ...out, error: 'invalid --run: empty' }
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: `invalid --format: ${fv ?? ''} (expected pretty | json)`,
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
- for (const k of kinds) lines.push(` ${k.padEnd(kindW)} ${WHAT_TO_DO[k]}`)
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}`)
@@ -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',
@@ -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 { executablePath, isExecutableMissing, killGraceMs } from '../util/index.js'
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
- argv0: 'sh',
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: true,
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
- executablePath('sh'),
672
- '-c',
673
- (guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand),
674
- ],
675
- {
676
- argv0: 'sh',
677
- cwd: opts.cwd,
678
- env: opts.env as Record<string, string>,
679
- stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
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: `allow.network` missing → block all (allowedDomains: []);
161
- * `true` → allow all (['*']); a domain list → exactly that list.
162
- * `deny.network` is always passed as deniedDomains.
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
- // `allow.network` / `deny.network` become SRT's domain lists. SRT requires
189
- // both to be present on any network config, so we always supply both;
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 ?? [])],