@vzn/vx 0.0.50 → 0.0.51

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.
Files changed (54) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migration.ts +1 -1
  53. package/src/workspace/project-loader.ts +53 -1
  54. package/src/workspace/workspace.ts +54 -14
package/src/cli/select.ts CHANGED
@@ -240,6 +240,7 @@ export async function resolveFilters(
240
240
  workspaceGlobOwners: (changed) =>
241
241
  workspaceGlobOwners(root, projects, changed, load, stagedOnce),
242
242
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
243
+ taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
243
244
  })
244
245
  affectedByFilter.set(f, names)
245
246
  } catch (err) {
@@ -325,11 +326,15 @@ export interface PickedTask {
325
326
  description?: string
326
327
  }
327
328
 
329
+ /**
330
+ * The task the user picks from a numbered menu; null when nothing was
331
+ * picked (said on stderr), `'interrupted'` for a Ctrl-C at the prompt.
332
+ */
328
333
  export async function pickTask(
329
334
  cwd: string,
330
335
  io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
331
336
  load: CliLoadOptions = {},
332
- ): Promise<PickedTask | null> {
337
+ ): Promise<PickedTask | null | 'interrupted'> {
333
338
  const projects = await loadWorkspaceProjects(cwd)
334
339
  // The staged load: a task a `project` plugin gave a config-less package
335
340
  // is on the menu, as it is in a run.
@@ -362,8 +367,30 @@ export async function pickTask(
362
367
  input: io.input ?? process.stdin,
363
368
  output: io.output ?? process.stdout,
364
369
  })
370
+ // On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
371
+ // rejects the pending question with an AbortError — which reached the
372
+ // user as `vx: AbortError: Aborted with Ctrl+C` and a stack, exit 1.
373
+ // A SIGINT listener tells the two apart: Ctrl-C is an interrupt (exit 130,
374
+ // as a run's), Ctrl-D an answer that never came.
375
+ const abort = new AbortController()
376
+ let interrupted = false
377
+ rl.on('SIGINT', () => {
378
+ interrupted = true
379
+ abort.abort()
380
+ })
365
381
  try {
366
- const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
382
+ let answer: string
383
+ try {
384
+ answer = (
385
+ await rl.question(`Pick a task [1-${entries.length}]: `, { signal: abort.signal })
386
+ ).trim()
387
+ } catch (err) {
388
+ if (!(err instanceof Error) || err.name !== 'AbortError') throw err
389
+ if (interrupted) return 'interrupted'
390
+ out.write('\n')
391
+ process.stderr.write(`vx run: no task picked\n`)
392
+ return null
393
+ }
367
394
  const n = Number(answer)
368
395
  if (!Number.isInteger(n) || n < 1 || n > entries.length) {
369
396
  process.stderr.write(`vx run: invalid selection: ${answer}\n`)
@@ -2,14 +2,17 @@
2
2
  // Asks the GitHub release API for the asset of this os/arch and the
3
3
  // SHA-256 digest it publishes, downloads the asset, verifies the digest,
4
4
  // writes next to the current executable, atomic rename over it. The
5
- // digest is what makes a cut transfer (a proxy that drops a connection,
6
- // a disk that fills) or a swapped asset a refusal instead of a binary
7
- // that does not start: the rename is the last step, and a mismatch
8
- // replaces nothing.
5
+ // digest is what makes a cut or corrupted transfer (a proxy that drops a
6
+ // connection, a disk that fills) a refusal instead of a binary that does
7
+ // not start: the rename is the last step, and a mismatch replaces
8
+ // nothing. It guards the TRANSFER only: the digest comes from the same
9
+ // release API as the download URL, and GitHub recomputes it when an asset
10
+ // is uploaded, so whoever can replace the asset replaces its digest too
11
+ // (item 1096). Nothing here verifies who built the binary.
9
12
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
10
13
  // deno upgrade — "update" is what package managers do to indexes.
11
14
 
12
- import { chmod, rename, rm } from 'node:fs/promises'
15
+ import { chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
13
16
  import { seeHelp } from './help.js'
14
17
  import { UserError } from '../util/index.js'
15
18
  import { VERSION } from '../version.js'
@@ -70,6 +73,8 @@ function assetName(): string {
70
73
 
71
74
  /** What the release API says about the asset this platform installs. */
72
75
  export interface ReleaseAsset {
76
+ /** The release's tag, as the API names it (`v0.4.0`). */
77
+ tag: string
73
78
  url: string
74
79
  /** Lower-case hex SHA-256 of the asset's bytes. */
75
80
  sha256: string
@@ -113,7 +118,16 @@ export function releaseAsset(release: unknown, name: string): ReleaseAsset {
113
118
  `vx upgrade: release ${tag} publishes no SHA-256 digest for ${name} — nothing to verify the download against, so it is not attempted`,
114
119
  )
115
120
  }
116
- return { url, sha256: m[1]!.toLowerCase() }
121
+ return { tag, url, sha256: m[1]!.toLowerCase() }
122
+ }
123
+
124
+ /**
125
+ * Whether a release tag names the version this binary is: the upgrade
126
+ * re-downloaded and replaced a binary with itself, printing `X → latest`
127
+ * (item 1098). Tags are `v<version>`; a bare version is accepted too.
128
+ */
129
+ export function isThisVersion(tag: string, version: string): boolean {
130
+ return tag === `v${version}` || tag === version
117
131
  }
118
132
 
119
133
  /**
@@ -134,8 +148,26 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
134
148
  }
135
149
  }
136
150
 
151
+ /**
152
+ * A response's body, read to the end. The headers can arrive and the
153
+ * connection drop before the body does (a proxy that cuts a transfer):
154
+ * Bun rejects the read with its own `TypeError` (`ECONNRESET`, "The socket
155
+ * connection was closed unexpectedly"), which `fetchOrRefuse` never sees
156
+ * and the CLI printed as an internal error with a stack.
157
+ */
158
+ async function readOrRefuse<T>(read: () => Promise<T>, url: string, what: string): Promise<T> {
159
+ try {
160
+ return await read()
161
+ } catch (err) {
162
+ const message = err instanceof Error ? err.message : String(err)
163
+ throw new UserError(
164
+ `vx upgrade: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
165
+ )
166
+ }
167
+ }
168
+
137
169
  /** The release document for `latest` or a tag, from the GitHub API. */
138
- async function fetchRelease(tag: string | undefined): Promise<unknown> {
170
+ export async function fetchRelease(tag: string | undefined): Promise<unknown> {
139
171
  const url =
140
172
  tag === undefined
141
173
  ? `https://api.github.com/repos/${REPO}/releases/latest`
@@ -149,25 +181,47 @@ async function fetchRelease(tag: string | undefined): Promise<unknown> {
149
181
  'read the release',
150
182
  )
151
183
  if (!res.ok) {
184
+ // GitHub answers an unauthenticated address past its hourly budget with
185
+ // 403 (or 429) and says so in the headers; "could not read the release
186
+ // (403)" read as a permissions fault (item 1098).
187
+ const reset = Number(res.headers.get('x-ratelimit-reset'))
188
+ const limited =
189
+ (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0'
152
190
  throw new UserError(
153
- `vx upgrade: could not read the release (${res.status}) — ${url}${res.status === 404 && tag !== undefined ? ` (is ${tag} a release tag?)` : ''}`,
191
+ limited
192
+ ? `vx upgrade: GitHub's API rate limit for this address is spent${Number.isFinite(reset) && reset > 0 ? ` until ${new Date(reset * 1000).toISOString()}` : ''} — re-run after that`
193
+ : `vx upgrade: could not read the release (${res.status}) — ${url}${res.status === 404 && tag !== undefined ? ` (is ${tag} a release tag?)` : ''}`,
154
194
  )
155
195
  }
156
- return (await res.json()) as unknown
196
+ // A body that is not JSON (a captive portal's page, a proxy's error
197
+ // page served with a 200) is the same refusal as a cut one.
198
+ return await readOrRefuse(() => res.json() as Promise<unknown>, url, 'read the release')
157
199
  }
158
200
 
159
201
  /**
160
202
  * Download `url`, verify its SHA-256 against `sha256`, and atomically
161
- * replace `dest` with it. Exported for tests (which stub `fetch` and
162
- * point `dest` at a tmp file); the CLI wires it to the release asset
163
- * and process.execPath.
203
+ * replace `dest` with it, keeping `dest`'s mode (and, as root, its owner):
204
+ * a 0750 install came back 0755 and owned by whoever upgraded (item 1097).
205
+ * `starts`, when given, is asked of the replaced binary; false puts the old
206
+ * one back, since the digest proves the bytes, not that this machine can
207
+ * run them (a CPU below the build's target, a `noexec` mount), and the
208
+ * upgrade reported success over a vx that no longer started. Exported for
209
+ * tests (which stub `fetch` and point `dest` at a tmp file); the CLI wires
210
+ * it to the release asset and process.execPath.
164
211
  */
165
- export async function replaceBinary(dest: string, url: string, sha256: string): Promise<void> {
212
+ export async function replaceBinary(
213
+ dest: string,
214
+ url: string,
215
+ sha256: string,
216
+ starts?: (dest: string) => boolean,
217
+ ): Promise<void> {
166
218
  const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release asset')
167
219
  if (!res.ok) {
168
220
  throw new UserError(`vx upgrade: download failed (${res.status}) — ${url}`)
169
221
  }
170
- const bytes = new Uint8Array(await res.arrayBuffer())
222
+ const bytes = new Uint8Array(
223
+ await readOrRefuse(() => res.arrayBuffer(), url, 'download the release asset'),
224
+ )
171
225
  if (bytes.byteLength === 0) {
172
226
  throw new UserError(`vx upgrade: empty download — ${url}`)
173
227
  }
@@ -178,27 +232,54 @@ export async function replaceBinary(dest: string, url: string, sha256: string):
178
232
  )
179
233
  }
180
234
  const tmp = `${dest}.upgrade-${process.pid}`
235
+ const old = `${dest}.previous-${process.pid}`
236
+ let kept = false
181
237
  try {
238
+ const was = await stat(dest).catch(() => null)
182
239
  await Bun.write(tmp, bytes)
183
- await chmod(tmp, 0o755)
240
+ // Its mode, executable wherever it is readable: a 0750 install stays
241
+ // group-only, and a binary is never left without its execute bits.
242
+ await chmod(tmp, was === null ? 0o755 : (was.mode & 0o7777) | ((was.mode & 0o444) >> 2))
243
+ if (was !== null && process.getuid?.() === 0) await chown(tmp, was.uid, was.gid)
244
+ // A second name for the binary being replaced, so a new one that does
245
+ // not start can be put back; the rename below stays the one atomic swap.
246
+ if (starts !== undefined && was !== null) {
247
+ await link(dest, old)
248
+ kept = true
249
+ }
184
250
  await rename(tmp, dest)
185
251
  } catch (err) {
186
252
  await rm(tmp, { force: true })
253
+ if (kept) await rm(old, { force: true })
187
254
  const msg = err instanceof Error ? err.message : String(err)
188
255
  throw new UserError(
189
256
  `vx upgrade: could not replace ${dest} (${msg}) — ` +
190
257
  `check permissions, or reinstall with npm install -g @vzn/vx`,
191
258
  )
192
259
  }
260
+ if (starts === undefined || starts(dest)) {
261
+ if (kept) await rm(old, { force: true })
262
+ return
263
+ }
264
+ if (kept) await rename(old, dest)
265
+ throw new UserError(
266
+ `vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
267
+ )
193
268
  }
194
269
 
195
270
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
196
- const tag = args.find((a) => !a.startsWith('-'))
197
271
  const unknown = args.find((a) => a.startsWith('-'))
198
272
  if (unknown !== undefined) {
199
273
  process.stderr.write(`vx upgrade: unknown flag: ${unknown}${seeHelp('upgrade')}\n`)
200
274
  return 1
201
275
  }
276
+ // One release is installed; a second tag was dropped without a word and
277
+ // the first one went in (`vx upgrade v0.0.22 v0.0.23`).
278
+ const [tag, extra] = args
279
+ if (extra !== undefined) {
280
+ process.stderr.write(`vx upgrade: unexpected argument: ${extra}${seeHelp('upgrade')}\n`)
281
+ return 1
282
+ }
202
283
  if (!isCompiledBinary()) {
203
284
  throw new UserError(
204
285
  'vx upgrade only works for the compiled binary. ' +
@@ -214,12 +295,19 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
214
295
  )
215
296
  }
216
297
  const asset = releaseAsset(await fetchRelease(tag), assetName())
298
+ if (isThisVersion(asset.tag, VERSION)) {
299
+ process.stdout.write(`vx upgrade: already at ${VERSION} (${asset.tag}) — nothing to do\n`)
300
+ return 0
301
+ }
217
302
  process.stdout.write(`vx upgrade: ${VERSION} → ${tag ?? 'latest'} (${dest})\n`)
218
- await replaceBinary(dest, asset.url, asset.sha256)
219
- // Report the replaced binary's own version — the new build speaks
220
- // for itself rather than this process guessing.
221
- const proc = Bun.spawnSync({ cmd: [dest, '--version'], stdout: 'pipe', stderr: 'pipe' })
222
- const v = new TextDecoder().decode(proc.stdout).trim()
223
- process.stdout.write(`vx upgrade: installed ${v || '(version check failed)'}\n`)
303
+ // The replaced binary's own version: the new build speaks for itself
304
+ // rather than this process guessing, and one that cannot is rolled back.
305
+ let installed = ''
306
+ await replaceBinary(dest, asset.url, asset.sha256, (bin) => {
307
+ const proc = Bun.spawnSync({ cmd: [bin, '--version'], stdout: 'pipe', stderr: 'pipe' })
308
+ installed = new TextDecoder().decode(proc.stdout).trim()
309
+ return proc.exitCode === 0 && installed.startsWith('vx ')
310
+ })
311
+ process.stdout.write(`vx upgrade: installed ${installed}\n`)
224
312
  return 0
225
313
  }
package/src/cli/watch.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  xxh3,
24
24
  } from '../util/index.js'
25
25
  import { asTrees } from '../cache/index.js'
26
- import { parseRunArgs, resolveRunOptions } from './run.js'
26
+ import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
27
27
  import {
28
28
  fingerprintClaims,
29
29
  forwardedSignal,
@@ -397,30 +397,37 @@ export function armWatcher(
397
397
  return { watcher, ready }
398
398
  }
399
399
 
400
- export async function watchCmd(args: readonly string[]): Promise<number> {
401
- const parsed = parseRunArgs(args)
402
- if (parsed.error) {
403
- process.stderr.write(`vx watch: ${parsed.error}\n`)
404
- return 1
405
- }
406
-
400
+ /**
401
+ * The run flags a watch loop cannot honour, as the refusal line it prints; null
402
+ * when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
403
+ * the help line and the completions, and a test holds the two together.
404
+ */
405
+ export function watchRefusal(parsed: RunArgs): string | null {
407
406
  if (parsed.dry !== undefined || parsed.graph !== undefined) {
408
- process.stderr.write(`vx watch: --dry / --graph are not supported in watch mode\n`)
409
- return 1
407
+ return 'vx watch: --dry / --graph are not supported in watch mode'
410
408
  }
411
409
  if (parsed.summarize !== undefined || parsed.profile !== undefined) {
412
- process.stderr.write(
413
- `vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)\n`,
414
- )
415
- return 1
410
+ return 'vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)'
416
411
  }
417
412
  // All three format ONE run's result and are consumed by `runCmd` alone, so
418
413
  // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
419
414
  // asks for the output watch already gives.
420
415
  if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
421
- process.stderr.write(
422
- `vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)\n`,
423
- )
416
+ return 'vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)'
417
+ }
418
+ return null
419
+ }
420
+
421
+ export async function watchCmd(args: readonly string[]): Promise<number> {
422
+ const parsed = parseRunArgs(args)
423
+ if (parsed.error) {
424
+ process.stderr.write(`vx watch: ${parsed.error}\n`)
425
+ return 1
426
+ }
427
+
428
+ const refused = watchRefusal(parsed)
429
+ if (refused !== null) {
430
+ process.stderr.write(`${refused}\n`)
424
431
  return 1
425
432
  }
426
433
  if (parsed.tasks.length === 0) {
@@ -509,8 +516,17 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
509
516
  // Uncaught, it ended watch at start while the same break mid-watch did
510
517
  // not (item 1017).
511
518
  let held: HeldPersistent | undefined
519
+ let refusedToStart = false
512
520
  try {
513
- held = (await runOrchestrator(opts)).persistent
521
+ const initial = await runOrchestrator(opts)
522
+ held = initial.persistent
523
+ // A run that failed having run nothing refused to start: a requested
524
+ // name no project declares (run() says which, with a "did you mean").
525
+ // `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
526
+ // same refusal on every change, since no edit to an input can declare
527
+ // a task. A name only the diff left out (`--affected`) is `ok` and
528
+ // keeps watching.
529
+ refusedToStart = !initial.ok && initial.outcomes.length === 0
514
530
  } catch (err) {
515
531
  process.stderr.write(
516
532
  `vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
@@ -520,6 +536,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
520
536
  await held?.stop(forwardedSignal(stop.signal.reason))
521
537
  return 0
522
538
  }
539
+ if (refusedToStart) return 1
523
540
 
524
541
  const load: CliLoadOptions = {
525
542
  ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
package/src/config.ts CHANGED
@@ -190,8 +190,10 @@ export interface TaskConfig {
190
190
  * write grant costs on Linux, is in `docs/schema.md` under
191
191
  * `exec.sandbox`.
192
192
  *
193
- * Paths are project-relative, absolute, or `~`-expanded — and are path
194
- * PREFIXES, never globs (a glob cannot be enforced by a mount).
193
+ * Paths are project-relative, absolute, or `~`-expanded, and may be
194
+ * globs: macOS matches the pattern in the policy, Linux expands it when
195
+ * the task starts (a mount cannot hold a pattern), so a file created
196
+ * later is not covered there — grant its directory.
195
197
  */
196
198
  export interface SandboxConfig {
197
199
  /** Capabilities granted on top of the task's own declarations. */
@@ -219,8 +221,11 @@ export interface SandboxConfig {
219
221
  * ignore: { write: ['*.bun-build'] } // bun --compile stages a random
220
222
  * // temp name in the cwd
221
223
  * ```
224
+ *
225
+ * A denial is classed read, write, systemInfo or network, so those four
226
+ * are what can be ignored.
222
227
  */
223
- ignore?: SandboxGrants
228
+ ignore?: SandboxIgnore
224
229
  /**
225
230
  * Accept a weaker sandbox when vx itself already runs inside one
226
231
  * (a container, another sandbox). Without it, nesting fails fast rather
@@ -231,15 +236,23 @@ export interface SandboxConfig {
231
236
  weakerNetworkIsolation?: boolean
232
237
  }
233
238
 
239
+ /** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
240
+ export interface SandboxIgnore {
241
+ read?: string[]
242
+ write?: string[]
243
+ systemInfo?: string[]
244
+ network?: string[]
245
+ }
246
+
234
247
  export interface SandboxGrants {
235
248
  /**
236
- * Readable path prefixes, added to the baseline — which is
249
+ * Readable paths or globs, added to the baseline — which is
237
250
  * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
238
251
  * source in the sandbox says so here.
239
252
  */
240
253
  read?: string[]
241
254
  /**
242
- * Writable path prefixes. The task can write NOTHING without these:
255
+ * Writable paths or globs. The task can write NOTHING without these:
243
256
  * declaring `cache.outputs.files` grants no write, and a task whose
244
257
  * only declaration is its outputs writes into a tmpfs that evaporates
245
258
  * (vx then warns that `cache.outputs` matched no files).
@@ -173,8 +173,9 @@ export interface TaskPlacement {
173
173
  readonly command: string
174
174
  /**
175
175
  * Must run on this machine: the task is persistent, depends (transitively)
176
- * on a persistent or sandboxed task, is sandboxed itself, or declares
177
- * `exec.remote: false`. A `remote` executor is never offered such a task.
176
+ * on a persistent or sandboxed task, is sandboxed itself, declares
177
+ * `exec.remote: false`, or folds a runtime probe into its key (this
178
+ * machine's `node -v`). A `remote` executor is never offered such a task.
178
179
  */
179
180
  readonly pinnedLocal: boolean
180
181
  /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
package/src/exec/index.ts CHANGED
@@ -6,7 +6,7 @@ export {
6
6
  runCommand,
7
7
  runPersistent,
8
8
  PersistentReadyError,
9
- shellQuote,
9
+ withForwardArgs,
10
10
  signalExitCode,
11
11
  exitSignal,
12
12
  type CaptureConfig,
@@ -82,6 +82,64 @@ export function shellQuote(arg: string): string {
82
82
  return `'${arg.replace(/'/g, `'\\''`)}'`
83
83
  }
84
84
 
85
+ /**
86
+ * Where a shell comment still open at the end of `command` starts, or -1.
87
+ * A `#` opens one only unquoted and at the start of a word (`a#b`, `$#` and
88
+ * `${#x}` are words), and a newline closes it.
89
+ */
90
+ function trailingCommentStart(command: string): number {
91
+ let quote = ''
92
+ let comment = -1
93
+ let atWordStart = true
94
+ for (let i = 0; i < command.length; i++) {
95
+ const c = command[i]!
96
+ if (comment >= 0) {
97
+ if (c === '\n') {
98
+ comment = -1
99
+ atWordStart = true
100
+ }
101
+ continue
102
+ }
103
+ if (quote === "'") {
104
+ if (c === "'") quote = ''
105
+ continue
106
+ }
107
+ if (c === '\\') {
108
+ i++
109
+ atWordStart = false
110
+ continue
111
+ }
112
+ if (quote === '"') {
113
+ if (c === '"') quote = ''
114
+ continue
115
+ }
116
+ if (c === "'" || c === '"') {
117
+ quote = c
118
+ atWordStart = false
119
+ continue
120
+ }
121
+ if (c === '#' && atWordStart) {
122
+ comment = i
123
+ continue
124
+ }
125
+ atWordStart = /[\s;&|()<>]/.test(c)
126
+ }
127
+ return comment
128
+ }
129
+
130
+ /**
131
+ * The command a task runs with the args after `--` appended, shell-quoted.
132
+ * They go before a trailing comment: appended after it, `echo args: # show`
133
+ * ran without them and said nothing (item 1060).
134
+ */
135
+ export function withForwardArgs(command: string, args: readonly string[] | undefined): string {
136
+ if (!args || args.length === 0) return command
137
+ const quoted = args.map(shellQuote).join(' ')
138
+ const comment = trailingCommentStart(command)
139
+ if (comment < 0) return `${command} ${quoted}`
140
+ return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
141
+ }
142
+
85
143
  // Any shell control/expansion character means the wrapping `sh` has real
86
144
  // work to do (chaining, pipes, redirects, globbing, variables, subshells,
87
145
  // backgrounding) and must stay resident.
@@ -296,6 +354,15 @@ export async function drainOrAbort(
296
354
  */
297
355
  const READY_MATCH_WINDOW_CHARS = 64 * 1024
298
356
 
357
+ /**
358
+ * Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
359
+ * cursor), OSC (titles, links) and the two-byte forms. Vite under
360
+ * `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
361
+ * (item 1059). The streamed bytes keep them; only the tested text drops them.
362
+ */
363
+ // eslint-disable-next-line no-control-regex -- ESC is the point
364
+ const TERMINAL_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g
365
+
299
366
  /**
300
367
  * Why a persistent task never became ready — the reason every label and
301
368
  * record reads instead of a fabricated exit (item 270). `exitCode` is the
@@ -420,11 +487,16 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
420
487
  }
421
488
 
422
489
  // Stream readers. Each stream owns a pending fragment so a regex
423
- // match isn't missed across chunk boundaries. The match runs over
424
- // the WHOLE fragment, including a trailing partial line — prompt-
425
- // style markers ("Listening on :3000" with no newline) would never
426
- // resolve ready under line-by-line-only matching. Complete lines
427
- // that didn't match are discarded after each test to bound memory.
490
+ // match isn't missed across chunk boundaries. The pattern is tested
491
+ // against each line on its own — without its break and without terminal
492
+ // escapes — so `^` and `$` anchor to a line as a user reads it; a whole
493
+ // fragment holding `booting\nready\n` failed `^ready` and `ready$`
494
+ // alike (item 1059). `\r` ends a line too: a progress bar's frames are
495
+ // lines to a reader. The trailing partial line is tested as well —
496
+ // prompt-style markers ("Listening on :3000" with no newline) would
497
+ // never resolve ready under complete-lines-only matching.
498
+ const readyLine = (re: RegExp, line: string): boolean =>
499
+ re.test(line.includes('\x1b') ? line.replace(TERMINAL_ESCAPE_RE, '') : line)
428
500
  const consumeChunks = async (
429
501
  stream: ReadableStream<Uint8Array> | number | undefined,
430
502
  isStderr: boolean,
@@ -442,23 +514,32 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
442
514
  else opts.onStdout?.(chunk)
443
515
  if (readyRe && readyAt === undefined) {
444
516
  fragment += chunk
445
- if (readyRe.test(fragment)) {
517
+ let start = 0
518
+ for (let i = 0; i < fragment.length; i++) {
519
+ const c = fragment.charCodeAt(i)
520
+ if (c !== 10 && c !== 13) continue
521
+ if (readyLine(readyRe, fragment.slice(start, i))) {
522
+ markReady()
523
+ fragment = ''
524
+ return
525
+ }
526
+ start = i + 1
527
+ }
528
+ fragment = fragment.slice(start)
529
+ if (readyLine(readyRe, fragment)) {
446
530
  markReady()
447
531
  fragment = ''
448
- } else {
449
- const lastNl = fragment.lastIndexOf('\n')
450
- if (lastNl >= 0) fragment = fragment.slice(lastNl + 1)
451
- // Dropping complete lines assumes the stream HAS line breaks.
452
- // `\r`-only output — a progress bar, a spinner — is one endless
453
- // line, so the trim above never fires and the fragment becomes a
454
- // third unbounded accumulator (measured 464 MiB in 6 s, and the
455
- // per-chunk `test` over an ever-growing string makes it quadratic
456
- // in CPU too). Keep the most RECENT window: a match may span the
457
- // chunk boundary, and no readiness marker is anywhere near this
458
- // long.
459
- if (fragment.length > READY_MATCH_WINDOW_CHARS) {
460
- fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
461
- }
532
+ return
533
+ }
534
+ // Dropping complete lines assumes the stream HAS line breaks. Output
535
+ // with none is one endless line, so the fragment would become a
536
+ // third unbounded accumulator (measured 464 MiB in 6 s, and the
537
+ // per-chunk `test` over an ever-growing string makes it quadratic
538
+ // in CPU too). Keep the most RECENT window: a match may span the
539
+ // chunk boundary, and no readiness marker is anywhere near this
540
+ // long.
541
+ if (fragment.length > READY_MATCH_WINDOW_CHARS) {
542
+ fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
462
543
  }
463
544
  }
464
545
  }
@@ -566,10 +647,7 @@ export function spawnFailureText(err: unknown, cwd: string, what = 'task'): stri
566
647
 
567
648
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
568
649
  const start = Date.now()
569
- const fullCommand =
570
- opts.forwardArgs && opts.forwardArgs.length > 0
571
- ? opts.command + ' ' + opts.forwardArgs.map(shellQuote).join(' ')
572
- : opts.command
650
+ const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
573
651
 
574
652
  let proc: ReturnType<typeof Bun.spawn>
575
653
  try {
@@ -82,7 +82,7 @@ const warnedSymlinkPunch = new Set<string>()
82
82
  * `--bind <out>` then `--ro-bind <readPath>`. When the read path is an
83
83
  * ancestor of the write path the read-only mount lands ON TOP of the
84
84
  * writable one and every write fails with `Read-only file system`
85
- * (`pushReadDenyDirMounts`, SRT 0.0.75 — its skip only covers the reverse
85
+ * (`pushReadDenyDirMounts`, SRT 0.0.75 and 0.0.76 — its skip only covers the reverse
86
86
  * nesting). Verified in a Linux container 2026-09-05:
87
87
  *
88
88
  * read=[proj] write=[proj/dist] → mkdir: Read-only file system