@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
package/src/util/paths.ts CHANGED
@@ -117,13 +117,44 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
117
117
  return [...new Set(out)]
118
118
  }
119
119
 
120
+ /**
121
+ * The directories whose snapshot covers every path the positive `globs`
122
+ * can match, or `null` when one reaches outside the project. A
123
+ * whole-subtree glob or a literal is its own tree
124
+ * ({@link wholeSubtreePrefixes}); any other glob is the tree under its
125
+ * static prefix, and a root-anchored one (`**\/dist/**`, Turbo's shape) is
126
+ * the whole project, `''`. Sound for the same reason: a file added or
127
+ * removed anywhere under a recorded directory bumps a recorded mtime. The
128
+ * snapshot's file list is then held to the entry's rows through the globs
129
+ * themselves, so a source file beside the outputs is no stray.
130
+ */
131
+ export function outputDirPrefixes(globs: readonly string[]): string[] | null {
132
+ if (globs.length === 0) return null
133
+ const dirs: string[] = []
134
+ for (const g of globs) {
135
+ const whole = wholeSubtreePrefixes([g])?.[0]
136
+ if (whole !== undefined) {
137
+ dirs.push(whole)
138
+ continue
139
+ }
140
+ const prefix = staticPrefix(g)
141
+ if (prefix === '.') return ['']
142
+ if (prefix.startsWith('/') || prefix.startsWith('!') || prefix.split('/').includes('..'))
143
+ return null
144
+ dirs.push(prefix)
145
+ }
146
+ const sorted = [...new Set(dirs)].sort()
147
+ return sorted.filter((d, i) => !sorted.slice(0, i).some((a) => d.startsWith(`${a}/`)))
148
+ }
149
+
120
150
  /**
121
151
  * The spellings a reader, Turbo and `.gitignore` all accept but a matcher
122
152
  * fed the raw string turns into NOTHING — and a task keyed on nothing
123
153
  * replays old outputs as a green hit (2026-09-10, probed one by one):
124
154
  * a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
125
155
  * `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
126
- * `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
127
158
  * after an optional `!`; a bare `.` is the empty entry the schema refuses.
128
159
  */
129
160
  export function normalizeGlob(glob: string): string {
@@ -143,6 +174,9 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
143
174
  const neg = glob.startsWith('!')
144
175
  let g = neg ? glob.slice(1) : glob
145
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)
146
180
  if (g === '.') g = ''
147
181
  if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
148
182
  return neg ? `!${g}` : g
@@ -176,7 +210,7 @@ export function isLiteralPattern(glob: string): boolean {
176
210
  * class written out nine times — a spelling that drifts is how two guards
177
211
  * come to answer differently for one pattern.
178
212
  *
179
- * 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
180
214
  * class and `\[id\]` names the route directory `[id]`. An escape counts as
181
215
  * a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
182
216
  * escape, never compared as a string; `asTrees` still reads an escaped-only
@@ -194,8 +228,9 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
194
228
 
195
229
  /**
196
230
  * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm, yarn,
197
- * Nx and Turbo read it; `Bun.Glob` has none, and reads the group as
198
- * 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
199
234
  * no file, so an edit to `src/x.ts` replayed a stale hit, and a member
200
235
  * glob `packages/!(x)` took in x (turborepo#3766). Every glob vx hands
201
236
  * `Bun.Glob` from a config refuses it.
@@ -215,8 +250,8 @@ export function taskGlob(pattern: string): Bun.Glob {
215
250
  * list across two thousand tasks, and the graph's overlap checks compiled
216
251
  * each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
217
252
  * comes from a config, so the set is bounded by what the configs say. Kept
218
- * per `Bun.Glob` in force: the playground and a test swap it, and a glob
219
- * 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.
220
255
  */
221
256
  const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
222
257
  const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
@@ -242,7 +277,7 @@ function compileTaskGlob(pattern: string): Bun.Glob {
242
277
  // A path with a slash on the end names no file vx lists; `Bun.Glob` reads
243
278
  // it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
244
279
  // stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
245
- // 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.
246
281
  glob.match = (rel: string): boolean =>
247
282
  rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
248
283
  return glob
@@ -333,20 +368,29 @@ export function stripTrailingSlash(p: string): string {
333
368
  * may not import `cache`, and a second copy is how the two would
334
369
  * disagree — which is exactly what item 441 measured about
335
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.
336
376
  */
337
377
  export function asTrees(patterns: readonly string[]): string[] {
338
378
  const out: string[] = []
339
379
  for (const raw of patterns) {
340
- const p = normalizeGlob(raw)
341
- if (!namesOnePath(p)) {
342
- out.push(p)
343
- 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}/**`)
344
390
  }
345
- const lit = stripTrailingSlash(p)
346
- if (lit.length === 0) continue
347
- out.push(lit, `${lit}/**`)
348
391
  }
349
- return out
392
+ // Two alternatives can fold to one entry (`{./src,src}`).
393
+ return [...new Set(out)]
350
394
  }
351
395
 
352
396
  /**
@@ -408,6 +452,23 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
408
452
  */
409
453
  const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
410
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
+
411
472
  /**
412
473
  * `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
413
474
  * those globs cross into other projects, and every project's `vx.lock.json`
@@ -457,7 +518,7 @@ export function slashBraceExpansions(pattern: string): string[] {
457
518
  return expandBraces(pattern, true)
458
519
  }
459
520
 
460
- /** `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. */
461
522
  export function braceAlternatives(pattern: string): string[] {
462
523
  return expandBraces(pattern, false)
463
524
  }
@@ -476,20 +537,53 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
476
537
  }
477
538
  if (close === -1) return [pattern]
478
539
  const body = pattern.slice(open + 1, close)
479
- 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
480
543
  const head = pattern.slice(0, open)
481
544
  const tail = pattern.slice(close + 1)
482
545
  const bounds = [open, ...cuts, close]
546
+ const alts = range ?? bounds.slice(1).map((end, k) => pattern.slice(bounds[k]! + 1, end))
483
547
  const out: string[] = []
484
- for (let k = 0; k + 1 < bounds.length; k++) {
485
- const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
486
- out.push(...expandBraces(head + alt + tail, slashOnly))
487
- }
548
+ for (const alt of alts) out.push(...expandBraces(head + alt + tail, slashOnly))
488
549
  return [...new Set(out)]
489
550
  }
490
551
  return [pattern]
491
552
  }
492
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
+
493
587
  /**
494
588
  * Whether a path git lists as UNTRACKED is an installed dependency: it sits
495
589
  * under a `node_modules` directory. Such a file is never an input, and the
@@ -511,3 +605,9 @@ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
511
605
  export function printable(s: string): string {
512
606
  return s.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
513
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
@@ -40,13 +40,28 @@ export const path = (text: string): Inline => ({ style: 'bold', text })
40
40
  export const cmd = (text: string): Inline => ({ style: 'bold', text })
41
41
  /** A secondary fact. */
42
42
  export const dim = (text: string): Inline => ({ style: 'dim', text })
43
+ /** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
44
+ export const place = (text: string): Inline => (text === 'local' ? text : id(text))
43
45
  /** Text in a tone's color. */
44
46
  export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
45
47
 
46
- function inline(x: Inline, c: ColorSupport): string {
47
- if (typeof x === 'string') return x
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
+
59
+ /** One inline as text, colored for `c`. */
60
+ export function inline(x: Inline, c: ColorSupport): string {
61
+ if (typeof x === 'string') return plainText(x)
48
62
  if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
49
- 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)
50
65
  switch (style) {
51
66
  case 'dim':
52
67
  return paint('', text, c, { dim: true })
@@ -78,9 +93,55 @@ export function status(t: Tone, verb: string, headline: Inline): Block {
78
93
  ])
79
94
  }
80
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
+
81
133
  /** Indented short lines: what is wrong, or what happened. */
82
134
  export function text(...items: Inline[]): Block {
83
- 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)))
84
145
  }
85
146
 
86
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') {
@@ -0,0 +1,67 @@
1
+ import { lstatSync, readlinkSync, writeFileSync } from 'node:fs'
2
+ import { mkdir, open, rename, rm } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { UserError } from './errors.js'
5
+
6
+ /**
7
+ * Refuses a `file` that is a symlink: a repo can carry `vx.config.ts ->
8
+ * /anywhere`, and a write through it lands outside the workspace. Called
9
+ * before the first of a set of writes, so a refusal leaves none half-made.
10
+ */
11
+ export function refuseSymlink(file: string): void {
12
+ let link: string
13
+ try {
14
+ if (!lstatSync(file).isSymbolicLink()) return
15
+ link = readlinkSync(file)
16
+ } catch {
17
+ // quiet: nothing there to write through
18
+ return
19
+ }
20
+ throw new UserError(
21
+ `refusing to write ${file}: it is a symlink to ${link}; remove the link and run again`,
22
+ )
23
+ }
24
+
25
+ /**
26
+ * Writes `text` to `file`, never through a link: a temp beside it, made
27
+ * with O_EXCL, renamed over it. A rename replaces a link put there after
28
+ * the check, never its target.
29
+ */
30
+ export async function writeNoFollow(file: string, text: string): Promise<void> {
31
+ refuseSymlink(file)
32
+ await mkdir(path.dirname(file), { recursive: true })
33
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
34
+ try {
35
+ const fh = await open(tmp, 'wx')
36
+ try {
37
+ await fh.writeFile(text)
38
+ } finally {
39
+ await fh.close()
40
+ }
41
+ await rename(tmp, file)
42
+ } catch (err) {
43
+ await rm(tmp, { force: true }).catch(() => {})
44
+ // The refusal names the file the user knows, as `writeLockfile`'s does.
45
+ const e = err as NodeJS.ErrnoException
46
+ if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
47
+ if (e.path === tmp) e.path = file
48
+ throw err
49
+ }
50
+ }
51
+
52
+ /** A `.gitignore` that hides its whole directory from git, itself included. */
53
+ export const IGNORE_ALL = '*\n'
54
+
55
+ /**
56
+ * Hides `dir`, a directory vx made, from git: an `IGNORE_ALL` `.gitignore`
57
+ * in it (the Cargo / Nx convention), written only when absent, so a
58
+ * user's own file wins. Never trusts a `.vx/.gitignore` to name it: an
59
+ * older init's, a user's own, or a cache moved elsewhere names none.
60
+ */
61
+ export function ignoreDir(dir: string): void {
62
+ try {
63
+ writeFileSync(path.join(dir, '.gitignore'), IGNORE_ALL, { flag: 'wx' })
64
+ } catch (err) {
65
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
66
+ }
67
+ }