@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/util/index.ts CHANGED
@@ -19,11 +19,14 @@ export {
19
19
  gitSpawnRefusal,
20
20
  isExecutableMissing,
21
21
  isOutOfFds,
22
+ gitFailure,
22
23
  notAWorkTree,
23
24
  OUT_OF_FDS_HINT,
25
+ thrownMessage,
24
26
  wantsJson,
25
27
  } from './errors.js'
26
28
  export { configErrorFrame, configRefusal } from './config-frame.js'
29
+ export { envFlagOn } from './env-flag.js'
27
30
  export {
28
31
  detectColors,
29
32
  dumbTerminal,
@@ -34,11 +37,11 @@ export {
34
37
  WARN,
35
38
  type ColorSupport,
36
39
  } from './colors.js'
37
- export { barWidth, gradientRule } from './report.js'
40
+ export { barWidth, gradientRule, ruleWidth } from './report.js'
38
41
  import * as ui from './ui.js'
39
42
  export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
40
43
  export { ui }
41
- export { xxh3, xxh3hex } from './hash.js'
44
+ export { keysSorted, xxh3, xxh3hex } from './hash.js'
42
45
  export {
43
46
  beginRun,
44
47
  mark,
@@ -48,7 +51,7 @@ export {
48
51
  stageTimes,
49
52
  type StageTime,
50
53
  } from './timing.js'
51
- export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
54
+ export { clampInt, formatElapsed, isMeasure, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
52
55
  export {
53
56
  asTrees,
54
57
  BUN_GLOB_WILDCARDS,
@@ -63,6 +66,7 @@ export {
63
66
  outputNever,
64
67
  restorableOutput,
65
68
  printable,
69
+ hasControl,
66
70
  relPosix,
67
71
  DOT_VX_GITATTRIBUTES,
68
72
  DOT_VX_GITIGNORE,
@@ -76,6 +80,9 @@ export {
76
80
  wholeSubtreePrefixes,
77
81
  slashBraceExpansions,
78
82
  braceAlternatives,
83
+ braceRange,
84
+ BRACE_RANGE_LIMIT,
85
+ writableUnder,
79
86
  } from './paths.js'
80
87
  export {
81
88
  claimExitForSignal,
@@ -102,7 +109,7 @@ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.
102
109
  export { executablePath, shellArgv, taskShell } from './which.js'
103
110
  export { procfsIsOwn } from './procfs.js'
104
111
  export { hangupIgnored } from './hangup.js'
105
- export { ESSENTIAL_ENV } from './essential-env.js'
112
+ export { ESSENTIAL_ENV, withoutWorkspaceBins } from './essential-env.js'
106
113
  export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
107
114
  export {
108
115
  checkIgnore,
@@ -112,9 +119,11 @@ export {
112
119
  wholeIgnored,
113
120
  } from './git-ignored.js'
114
121
  export { realPath, realpathOf } from './real-path.js'
122
+ export { IGNORE_ALL, ignoreDir, refuseSymlink, writeNoFollow } from './write-file.js'
115
123
  export {
124
+ heldSecrets,
116
125
  maskedCommand,
117
- maskedEmitter,
126
+ maskedStreams,
118
127
  maskedLine,
119
128
  MASKED,
120
129
  secretMask,
@@ -131,3 +140,10 @@ export {
131
140
  type SecretUnavailableReason,
132
141
  } from './secrets.js'
133
142
  export { terminalOf, type SecretTerminal } from './hidden-prompt.js'
143
+ export {
144
+ DependencySpecError,
145
+ compileTaskPattern,
146
+ isTaskPattern,
147
+ parseDependencySpec,
148
+ type DependencySpec,
149
+ } from './dependency-spec.js'
package/src/util/num.ts CHANGED
@@ -21,6 +21,14 @@ export function clampInt(n: number, min: number, max: number): number {
21
21
  return Math.min(max, Math.max(min, Math.floor(n)))
22
22
  }
23
23
 
24
+ /**
25
+ * A timing or need SQLite may keep: finite and not below zero. Infinity
26
+ * stored as REAL reads back through `group_concat` as `Inf`, a NaN forecast.
27
+ */
28
+ export function isMeasure(v: number): boolean {
29
+ return Number.isFinite(v) && v >= 0
30
+ }
31
+
24
32
  /**
25
33
  * Parse a PLAIN decimal integer from user input; `null` for anything else.
26
34
  *
package/src/util/paths.ts CHANGED
@@ -153,7 +153,8 @@ export function outputDirPrefixes(globs: readonly string[]): string[] | null {
153
153
  * replays old outputs as a green hit (2026-09-10, probed one by one):
154
154
  * a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
155
155
  * `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
156
- * `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied
156
+ * `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job), and a
157
+ * trailing `/.`, read as a trailing `/`. Applied
157
158
  * after an optional `!`; a bare `.` is the empty entry the schema refuses.
158
159
  */
159
160
  export function normalizeGlob(glob: string): string {
@@ -173,6 +174,9 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
173
174
  const neg = glob.startsWith('!')
174
175
  let g = neg ? glob.slice(1) : glob
175
176
  g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
177
+ // A trailing `/.` is the directory, as a trailing `/` is: `*/.` matched
178
+ // no file and `!gen/.` took nothing back (2026-10-28).
179
+ if (g.endsWith('/.')) g = g.slice(0, -1)
176
180
  if (g === '.') g = ''
177
181
  if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
178
182
  return neg ? `!${g}` : g
@@ -206,7 +210,7 @@ export function isLiteralPattern(glob: string): boolean {
206
210
  * class written out nine times — a spelling that drifts is how two guards
207
211
  * come to answer differently for one pattern.
208
212
  *
209
- * A task glob is a standard glob (M, 2026-10-18): `[...]` is a character
213
+ * A task glob is a standard glob (2026-10-18): `[...]` is a character
210
214
  * class and `\[id\]` names the route directory `[id]`. An escape counts as
211
215
  * a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
212
216
  * escape, never compared as a string; `asTrees` still reads an escaped-only
@@ -224,8 +228,9 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
224
228
 
225
229
  /**
226
230
  * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm, yarn,
227
- * Nx and Turbo read it; `Bun.Glob` has none, and reads the group as
228
- * literal text or as a plain wildcard. A task input `src/@(x|y).ts` keyed
231
+ * Nx and Turbo read it; `Bun.Glob` had none before 1.4.4, and vx's own
232
+ * matchers still read the group as literal text or as a plain wildcard.
233
+ * A task input `src/@(x|y).ts` keyed
229
234
  * no file, so an edit to `src/x.ts` replayed a stale hit, and a member
230
235
  * glob `packages/!(x)` took in x (turborepo#3766). Every glob vx hands
231
236
  * `Bun.Glob` from a config refuses it.
@@ -245,8 +250,8 @@ export function taskGlob(pattern: string): Bun.Glob {
245
250
  * list across two thousand tasks, and the graph's overlap checks compiled
246
251
  * each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
247
252
  * comes from a config, so the set is bounded by what the configs say. Kept
248
- * per `Bun.Glob` in force: the playground and a test swap it, and a glob
249
- * built by one is not the other's.
253
+ * per `Bun.Glob` in force: a test swaps it, and a glob built by one is not
254
+ * the other's.
250
255
  */
251
256
  const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
252
257
  const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
@@ -272,7 +277,7 @@ function compileTaskGlob(pattern: string): Bun.Glob {
272
277
  // A path with a slash on the end names no file vx lists; `Bun.Glob` reads
273
278
  // it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
274
279
  // stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
275
- // is built by whatever `Bun.Glob` is at the call (the playground's port).
280
+ // is built by whatever `Bun.Glob` is at the call.
276
281
  glob.match = (rel: string): boolean =>
277
282
  rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
278
283
  return glob
@@ -363,20 +368,29 @@ export function stripTrailingSlash(p: string): string {
363
368
  * may not import `cache`, and a second copy is how the two would
364
369
  * disagree — which is exactly what item 441 measured about
365
370
  * `staticPrefix`.
371
+ *
372
+ * A brace is expanded first and each alternative read alone: as one
373
+ * pattern, `{src,lib}` never became the two trees and `{./src,lib}/**`
374
+ * kept a `./` no git path holds, so an edit under either was a stale hit
375
+ * and the same output glob saved an empty artifact.
366
376
  */
367
377
  export function asTrees(patterns: readonly string[]): string[] {
368
378
  const out: string[] = []
369
379
  for (const raw of patterns) {
370
- const p = normalizeGlob(raw)
371
- if (!namesOnePath(p)) {
372
- out.push(p)
373
- continue
380
+ const alternatives = raw.includes('{') ? braceAlternatives(raw) : [raw]
381
+ for (const alt of alternatives) {
382
+ const p = normalizeGlob(alt)
383
+ if (!namesOnePath(p)) {
384
+ out.push(p)
385
+ continue
386
+ }
387
+ const lit = stripTrailingSlash(p)
388
+ if (lit.length === 0) continue
389
+ out.push(lit, `${lit}/**`)
374
390
  }
375
- const lit = stripTrailingSlash(p)
376
- if (lit.length === 0) continue
377
- out.push(lit, `${lit}/**`)
378
391
  }
379
- return out
392
+ // Two alternatives can fold to one entry (`{./src,src}`).
393
+ return [...new Set(out)]
380
394
  }
381
395
 
382
396
  /**
@@ -438,6 +452,23 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
438
452
  */
439
453
  const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
440
454
 
455
+ /**
456
+ * Whether vx may write or delete at `real`, a path a link on the way
457
+ * resolved: under `realBase` and not in its `.git`, in any case (a
458
+ * case-insensitive disk reads `.GIT` as `.git`). Names are held to
459
+ * `OUTPUT_NEVER` lexically; this holds where a link lands
460
+ * (vercel/turborepo#14808). Only the base's own: a deeper `.git` (a
461
+ * submodule under `gen/**`, a build's `dist/.git`) is an output an entry
462
+ * may name, and refused only once it existed on disk, a restore failed by
463
+ * what the last run left.
464
+ */
465
+ export function writableUnder(real: string, realBase: string): boolean {
466
+ if (real === realBase) return true
467
+ if (!real.startsWith(realBase + path.sep)) return false
468
+ const top = real.slice(realBase.length + 1).split(path.sep, 1)[0]!
469
+ return top.toLowerCase() !== '.git'
470
+ }
471
+
441
472
  /**
442
473
  * `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
443
474
  * those globs cross into other projects, and every project's `vx.lock.json`
@@ -487,7 +518,7 @@ export function slashBraceExpansions(pattern: string): string[] {
487
518
  return expandBraces(pattern, true)
488
519
  }
489
520
 
490
- /** `pattern` with every brace group of two or more alternatives expanded. */
521
+ /** `pattern` with every brace group of two or more alternatives, or a range, expanded. */
491
522
  export function braceAlternatives(pattern: string): string[] {
492
523
  return expandBraces(pattern, false)
493
524
  }
@@ -506,20 +537,53 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
506
537
  }
507
538
  if (close === -1) return [pattern]
508
539
  const body = pattern.slice(open + 1, close)
509
- if (cuts.length === 0 || (slashOnly && !body.includes('/'))) continue
540
+ if (slashOnly && !body.includes('/')) continue
541
+ const range = cuts.length === 0 ? braceRange(body) : null
542
+ if (cuts.length === 0 && range === null) continue
510
543
  const head = pattern.slice(0, open)
511
544
  const tail = pattern.slice(close + 1)
512
545
  const bounds = [open, ...cuts, close]
546
+ const alts = range ?? bounds.slice(1).map((end, k) => pattern.slice(bounds[k]! + 1, end))
513
547
  const out: string[] = []
514
- for (let k = 0; k + 1 < bounds.length; k++) {
515
- const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
516
- out.push(...expandBraces(head + alt + tail, slashOnly))
517
- }
548
+ for (const alt of alts) out.push(...expandBraces(head + alt + tail, slashOnly))
518
549
  return [...new Set(out)]
519
550
  }
520
551
  return [pattern]
521
552
  }
522
553
 
554
+ const NUMERIC_RANGE = /^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/
555
+ const LETTER_RANGE = /^(?:([a-z])\.\.([a-z])|([A-Z])\.\.([A-Z]))(?:\.\.(-?\d+))?$/
556
+ /** The most names a brace range may name: each is a glob vx compiles. */
557
+ export const BRACE_RANGE_LIMIT = 1024
558
+
559
+ /**
560
+ * A brace range's names, as `Bun.Glob` reads them since 1.4.4 (bash's
561
+ * rule): `1..3`, `3..1`, `01..03` padded, `a..c`, `1..10..3` stepped; null
562
+ * for any other body, a mixed-case one (`A..c` spans `[`, `\` and `]`) and
563
+ * one past `BRACE_RANGE_LIMIT`. vx expands it like a comma brace, so every matcher
564
+ * names the files `Bun.Glob` does.
565
+ */
566
+ export function braceRange(body: string): string[] | null {
567
+ const num = NUMERIC_RANGE.exec(body)
568
+ const letter = num === null ? LETTER_RANGE.exec(body) : null
569
+ const m =
570
+ num ?? (letter && [letter[0], letter[1] ?? letter[3], letter[2] ?? letter[4], letter[5]])
571
+ if (m === null) return null
572
+ const from = num ? Number(m[1]) : m[1]!.charCodeAt(0)
573
+ const to = num ? Number(m[2]) : m[2]!.charCodeAt(0)
574
+ const step = Math.abs(Number(m[3] ?? 1)) || 1
575
+ if (Math.abs(to - from) / step >= BRACE_RANGE_LIMIT) return null
576
+ const padded = num !== null && [m[1]!, m[2]!].some((n) => /^-?0\d/.test(n))
577
+ const width = padded ? Math.max(m[1]!.length, m[2]!.length) : 0
578
+ const out: string[] = []
579
+ for (let i = from; from <= to ? i <= to : i >= to; i += from <= to ? step : -step) {
580
+ if (!num) out.push(String.fromCharCode(i))
581
+ else if (i < 0) out.push(`-${String(-i).padStart(width - 1, '0')}`)
582
+ else out.push(String(i).padStart(width, '0'))
583
+ }
584
+ return out
585
+ }
586
+
523
587
  /**
524
588
  * Whether a path git lists as UNTRACKED is an installed dependency: it sits
525
589
  * under a `node_modules` directory. Such a file is never an input, and the
@@ -541,3 +605,9 @@ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
541
605
  export function printable(s: string): string {
542
606
  return s.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
543
607
  }
608
+
609
+ /** Whether `s` holds a C0/C1 control or DEL, which `printable` would escape. */
610
+ export function hasControl(s: string): boolean {
611
+ // oxlint-disable-next-line no-control-regex
612
+ return /[\x00-\x1f\x7f-\x9f]/.test(s)
613
+ }
@@ -14,10 +14,15 @@ export function barWidth(colors: ColorSupport): number {
14
14
  return columns === undefined ? 50 : Math.max(10, Math.min(50, columns - LABEL))
15
15
  }
16
16
 
17
+ /** A rule's width, flush with the bars: the footer's and a task frame's section rules. */
18
+ export function ruleWidth(colors: ColorSupport): number {
19
+ return LABEL + barWidth(colors)
20
+ }
21
+
17
22
  /** `─ <mark> ` + dashes, flush with the bars. Shared by the run summary and every report. */
18
23
  export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
19
24
  const head = `─ `
20
- const width = LABEL + barWidth(colors)
25
+ const width = ruleWidth(colors)
21
26
  const dashes = '─'.repeat(Math.max(8, width - 3 - mark.length))
22
27
  if (!colors.enabled) return `${head}${mark} ${dashes}`
23
28
  const chunks = 8
@@ -23,6 +23,7 @@ const NOT_SECRET = /_(FILE|PATH|DIR)$|^GIT_CONFIG_KEY_\d+$/i
23
23
  */
24
24
  const secretNames = new Map<string, boolean>()
25
25
 
26
+ /** Whether vx masks a variable's value by its name alone (`NPM_TOKEN`, not `NPM_TOKEN_FILE`). */
26
27
  export function secretNamed(name: string): boolean {
27
28
  let secret = secretNames.get(name)
28
29
  if (secret === undefined) {
@@ -46,9 +47,28 @@ const MIN_SECRET_CHARS = 6
46
47
 
47
48
  export const MASKED = '***'
48
49
 
50
+ /**
51
+ * Secrets this process holds that no environment names: a plugin's
52
+ * keychain answers (`ctx.secret`). A server's reply that echoed one
53
+ * printed it whole in the warning, beside masked task output.
54
+ */
55
+ const held = new Set<string>()
56
+
57
+ /** `value` is masked wherever this process's secrets are, from now on. */
58
+ export function holdSecret(value: string): void {
59
+ held.add(value)
60
+ }
61
+
62
+ /** How many values `holdSecret` holds: a mask built with fewer is stale. */
63
+ export function heldSecrets(): number {
64
+ return held.size
65
+ }
66
+
49
67
  export interface SecretMask {
50
68
  /** `text` with every secret value replaced. */
51
69
  mask(text: string): string
70
+ /** Where `mask` replaces a value in `text`: `[start, end)` pairs, in order. */
71
+ spans(text: string): Array<[start: number, end: number]>
52
72
  /**
53
73
  * A mask for a stream of chunks: a value split across two chunks is
54
74
  * still caught, by holding back only a tail that could begin one until
@@ -101,6 +121,8 @@ export function secretMask(
101
121
  }
102
122
  for (const name of named) add(source[name])
103
123
  }
124
+ // This process's secrets: the environment's and the held ones.
125
+ if (sources.includes(process.env)) for (const value of held) add(value)
104
126
  if (values.size === 0) return null
105
127
  // Longest first, so a value holding another is replaced whole.
106
128
  const sorted = [...values].sort((a, b) => b.length - a.length)
@@ -137,6 +159,13 @@ export function secretMask(
137
159
  }
138
160
  return {
139
161
  mask,
162
+ spans(text) {
163
+ const out: Array<[number, number]> = []
164
+ pattern.lastIndex = 0
165
+ for (let m = pattern.exec(text); m !== null; m = pattern.exec(text))
166
+ out.push([m.index, m.index + m[0].length])
167
+ return out
168
+ },
140
169
  maskCut(head, tail) {
141
170
  let h = head.length - partialTail(head)
142
171
  pattern.lastIndex = 0
@@ -202,7 +231,7 @@ export interface TaskEnvSecrets {
202
231
  * value is written whole or in chunks that arrive together; one split by
203
232
  * a pause longer than that is not caught.
204
233
  */
205
- export function maskedEmitter(
234
+ function maskedEmitter(
206
235
  mask: SecretMask,
207
236
  emit: (text: string) => void,
208
237
  idleMs = 25,
@@ -226,3 +255,35 @@ export function maskedEmitter(
226
255
  end,
227
256
  }
228
257
  }
258
+
259
+ /**
260
+ * A task's two streams, each through `maskedEmitter`, in the order the
261
+ * task wrote them. Each held its own tail, so a stdout tail that could
262
+ * begin a value waited out the idle bound while stderr printed, and the
263
+ * run log, live and cached, put the stderr line first. A chunk on one
264
+ * stream now emits the other's held tail before it: a value split by a
265
+ * write to the other stream is not caught, as one split by a pause.
266
+ */
267
+ export function maskedStreams(
268
+ mask: SecretMask,
269
+ emitOut: (text: string) => void,
270
+ emitErr: (text: string) => void,
271
+ idleMs = 25,
272
+ ): { out(chunk: string): void; err(chunk: string): void; end(): void } {
273
+ const out = maskedEmitter(mask, emitOut, idleMs)
274
+ const err = maskedEmitter(mask, emitErr, idleMs)
275
+ return {
276
+ out(chunk) {
277
+ err.end()
278
+ out.push(chunk)
279
+ },
280
+ err(chunk) {
281
+ out.end()
282
+ err.push(chunk)
283
+ },
284
+ end() {
285
+ out.end()
286
+ err.end()
287
+ },
288
+ }
289
+ }
@@ -1,10 +1,11 @@
1
1
  // A plugin's secrets, kept in the OS keychain (`Bun.secrets`) under the
2
2
  // plugin's own name, and asked for on the terminal when a plugin offers a
3
- // prompt and someone can answer (owner, 2026-10-17). Core reads no
3
+ // prompt and someone can answer (2026-10-17). Core reads no
4
4
  // environment here: a variable, an option or a config file is the
5
5
  // plugin's to check first, so the keychain is always the last source.
6
6
 
7
7
  import { type SecretTerminal, terminalOf } from './hidden-prompt.js'
8
+ import { holdSecret } from './secret-mask.js'
8
9
 
9
10
  /** What `ctx.secret` is asked: the entry's name, and the question to ask on a terminal. */
10
11
  export interface SecretRequest {
@@ -114,7 +115,10 @@ export class SecretBroker {
114
115
  let storeDown = false
115
116
  try {
116
117
  const stored = await this.store.get(entry)
117
- if (stored) return stored
118
+ if (stored) {
119
+ holdSecret(stored)
120
+ return stored
121
+ }
118
122
  } catch {
119
123
  // No keychain (headless Linux without libsecret).
120
124
  storeDown = true
@@ -124,6 +128,7 @@ export class SecretBroker {
124
128
  const asked = this.prompting.then(() => this.ask(req.key, req.prompt!))
125
129
  this.prompting = asked.catch(() => undefined)
126
130
  const answer = await asked
131
+ holdSecret(answer)
127
132
  try {
128
133
  await this.store.set({ ...entry, value: answer })
129
134
  } catch (err) {
package/src/util/size.ts CHANGED
@@ -24,11 +24,13 @@ export function parseSize(input: string): number | null {
24
24
  /**
25
25
  * Parse `<n><unit>` where unit is ms/s/m/h/d, case-insensitively (`parseSize`
26
26
  * has always been case-insensitive; this matched only lowercase, so `30D`
27
- * was rejected while `1GB` and `1gb` both worked).
27
+ * was rejected while `1GB` and `1gb` both worked). Minutes are `m` only:
28
+ * `M` is a month to systemd, and `--older-than 1M` read as a minute
29
+ * empties the cache.
28
30
  */
29
31
  export function parseDuration(input: string): number | null {
30
32
  const m = input.match(/^(\d+)(ms|[smhd])$/i)
31
- if (!m) return null
33
+ if (!m || m[2] === 'M') return null
32
34
  const n = parseDecimalInt(m[1]!)
33
35
  if (n === null) return null
34
36
  // Lowercase before the switch: with the /i flag an uppercase `M` would
@@ -55,8 +57,14 @@ export function formatBytes(n: number): string {
55
57
  if (n < 1024) return `${n} B`
56
58
  const units = ['KB', 'MB', 'GB', 'TB']
57
59
  let v = n / 1024
60
+ // The tier is the printed value's: 1 MiB less a byte printed `1024 KB`,
61
+ // and 10 KiB less one `10.0 KB`.
62
+ const shown = (x: number): string => {
63
+ const one = x.toFixed(1)
64
+ return Number(one) < 10 ? one : x.toFixed(0)
65
+ }
58
66
  for (const u of units) {
59
- if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
67
+ if (Number(shown(v)) < 1024) return `${shown(v)} ${u}`
60
68
  v /= 1024
61
69
  }
62
70
  return `${v.toFixed(0)} PB`
package/src/util/tail.ts CHANGED
@@ -46,7 +46,12 @@ export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS
46
46
  // A single chunk over the cap is the one place we copy.
47
47
  if (t.chars > limit) {
48
48
  const only = t.chunks[0]!
49
- const keep = only.slice(only.length - limit)
49
+ let start = only.length - limit
50
+ // A cut between a surrogate pair opened the frame on a lone low
51
+ // surrogate, which the terminal's UTF-8 encode prints as U+FFFD.
52
+ const first = only.charCodeAt(start)
53
+ if (first >= 0xdc00 && first <= 0xdfff) start++
54
+ const keep = only.slice(start)
50
55
  t.dropped += only.length - keep.length
51
56
  t.chunks[0] = keep
52
57
  t.chars = keep.length
@@ -9,6 +9,7 @@
9
9
  // prints (a push per stage, ~15 a run): a telemetry sink draws them as
10
10
  // spans (`stageTimes`). The per-call `span`s stay off unless enabled.
11
11
 
12
+ import { detectColors, paint } from './colors.js'
12
13
  const enabled = process.env.VX_DEBUG_TIMING === 'true' || process.env.VX_DEBUG_TIMING === '1'
13
14
  let t0 = Bun.nanoseconds()
14
15
  // The wall clock at t0, read then: `performance.timeOrigin` plus the
@@ -117,5 +118,16 @@ export function printTimings(): void {
117
118
  ' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
118
119
  )
119
120
  }
120
- process.stderr.write(lines.join('\n') + '\n')
121
+ const c = detectColors(process.stderr)
122
+ process.stderr.write(
123
+ `${lines
124
+ .map((l) =>
125
+ l.startsWith('[vx timing]')
126
+ ? `${paint('#06b6d4', '[vx timing]', c, { bold: true })}${paint('', l.slice(11), c, { dim: true })}`
127
+ : l.startsWith(' (')
128
+ ? paint('', l, c, { dim: true })
129
+ : l,
130
+ )
131
+ .join('\n')}\n`,
132
+ )
121
133
  }
package/src/util/ui.ts CHANGED
@@ -45,11 +45,23 @@ export const place = (text: string): Inline => (text === 'local' ? text : id(tex
45
45
  /** Text in a tone's color. */
46
46
  export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
47
47
 
48
+ // oxlint-disable-next-line no-control-regex
49
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
50
+
51
+ /**
52
+ * `s` with every control byte but a tab and a line break as `\xNN`: a
53
+ * notice quotes paths and names, anyone's bytes, and one holding `ESC ]0;`
54
+ * retitled the terminal. The colors are the components' own, added after.
55
+ */
56
+ const plainText = (s: string): string =>
57
+ s.replace(CONTROL, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`)
58
+
48
59
  /** One inline as text, colored for `c`. */
49
60
  export function inline(x: Inline, c: ColorSupport): string {
50
- if (typeof x === 'string') return x
61
+ if (typeof x === 'string') return plainText(x)
51
62
  if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
52
- const { style, text } = x as { style: Style; text: string }
63
+ const { style } = x as { style: Style; text: string }
64
+ const text = plainText((x as { text: string }).text)
53
65
  switch (style) {
54
66
  case 'dim':
55
67
  return paint('', text, c, { dim: true })
@@ -81,9 +93,55 @@ export function status(t: Tone, verb: string, headline: Inline): Block {
81
93
  ])
82
94
  }
83
95
 
96
+ /**
97
+ * A line vx says mid-run (`[vx] cache save failed: …`, `vx watch: …`), its
98
+ * words as they always read and its tag in the tone's hue; a line with no
99
+ * tag is painted whole. One string, no newline: the run's own streams write it.
100
+ */
101
+ export function tagged(t: Tone, line: string, c: ColorSupport): string {
102
+ const body = line.replace(/\n+$/, '')
103
+ const tag = /^(?:\[vx\]|vx(?: [a-z][\w-]*){0,2}:|[\w@/.-]+: )/.exec(body)?.[0].trimEnd()
104
+ return tag === undefined
105
+ ? paint(HUE[t], body, c)
106
+ : `${paint(HUE[t], tag, c, { bold: true })}${body.slice(tag.length)}`
107
+ }
108
+
109
+ /** `tagged` on a stream (stderr unless told), colored for it. */
110
+ export function say(t: Tone, line: string, stream: 'out' | 'err' = 'err'): void {
111
+ const s = stream === 'out' ? process.stdout : process.stderr
112
+ s.write(`${tagged(t, line, detectColors(s))}\n`)
113
+ }
114
+
115
+ /**
116
+ * A file or excerpt shown whole (`vx init --dry`, `vx docs`): its title rule
117
+ * bold, its body behind a dim gutter. Uncolored it is the bare text, so a
118
+ * pipe gets the bytes it would copy.
119
+ */
120
+ export function excerpt(title: string, body: string): Block {
121
+ return block((c) => {
122
+ if (!c.enabled) return [`── ${title} ──`, ...body.split('\n')]
123
+ const gutter = paint(INFO, '│', c, { dim: true })
124
+ return [
125
+ paint(INFO, `── ${title} ──`, c, { bold: true }),
126
+ ...body
127
+ .split('\n')
128
+ .map((l, i, all) => (i === all.length - 1 && l === '' ? l : `${gutter} ${l}`)),
129
+ ]
130
+ })
131
+ }
132
+
84
133
  /** Indented short lines: what is wrong, or what happened. */
85
134
  export function text(...items: Inline[]): Block {
86
- return block((c) => items.flatMap((i) => lines(i, c).map((l) => `${INDENT}${l}`)))
135
+ return block((c) =>
136
+ items.flatMap((i) =>
137
+ lines(typeof i === 'string' ? prose(i) : i, c).map((l) => `${INDENT}${l}`),
138
+ ),
139
+ )
140
+ }
141
+
142
+ /** Plain words dim, each `command` in them bold: no line of vx reads plain white. */
143
+ function prose(text: string): Inline {
144
+ return text.split(/(`[^`\n]+`)/).map((p, i) => (i % 2 === 1 ? cmd(p) : p === '' ? p : dim(p)))
87
145
  }
88
146
 
89
147
  /** An indented dim line: why it matters. */
package/src/util/which.ts CHANGED
@@ -53,7 +53,7 @@ let dashMissedOn: string | undefined
53
53
  * The shell vx runs commands in: `dash` on macOS when vx's PATH has it,
54
54
  * else `sh` on vx's PATH. macOS's `/bin/sh` is a stub that execs bash, two
55
55
  * images for one shell, and bash where Linux runs dash, so a command read
56
- * one way on a Mac and another on Linux CI (owner, 2026-10-04).
56
+ * one way on a Mac and another on Linux CI (2026-10-04).
57
57
  */
58
58
  export function taskShell(): string {
59
59
  if (process.platform === 'darwin') {