@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -56,6 +56,11 @@ export interface OutputWriter {
56
56
  settle(): void
57
57
  /** Permanently remove the status display; later set* is a no-op. */
58
58
  clearStatus(): void
59
+ /**
60
+ * Erase the status display and draw none until the returned release:
61
+ * a prompt on the terminal (a secret's) waits for its answer there.
62
+ */
63
+ hold(): () => void
59
64
  }
60
65
 
61
66
  export interface OutputWriterOptions {
@@ -108,6 +113,7 @@ export function createOutputWriter(
108
113
  // `regionRows`.
109
114
  let shownRows = 0
110
115
  let dead = false
116
+ let holds = 0
111
117
  let lastDraw = -Infinity
112
118
  // Trailing draw scheduled when a forced set lands inside the floor.
113
119
  let trailing: ReturnType<typeof setTimeout> | null = null
@@ -196,7 +202,7 @@ export function createOutputWriter(
196
202
  const set = (lines: readonly string[], o: { force?: boolean }): void => {
197
203
  if (!enabled || dead) return
198
204
  current = lines
199
- if (!atLineStart) return
205
+ if (!atLineStart || holds > 0) return
200
206
  const since = now() - lastDraw
201
207
  if (o.force) {
202
208
  if (since >= forceFloorMs) {
@@ -209,7 +215,7 @@ export function createOutputWriter(
209
215
  if (trailing === null) {
210
216
  trailing = setTimeout(() => {
211
217
  trailing = null
212
- if (dead || current === null || !atLineStart) return
218
+ if (dead || current === null || !atLineStart || holds > 0) return
213
219
  draw()
214
220
  }, forceFloorMs - since)
215
221
  trailing.unref?.()
@@ -241,7 +247,7 @@ export function createOutputWriter(
241
247
  }
242
248
  stream.write(chunk)
243
249
  if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
244
- if (!dead && current !== null && atLineStart) draw()
250
+ if (!dead && current !== null && atLineStart && holds === 0) draw()
245
251
  },
246
252
  writeTo(target, chunk) {
247
253
  if (target === stream) return this.write(chunk)
@@ -257,7 +263,7 @@ export function createOutputWriter(
257
263
  shownRows = 0
258
264
  }
259
265
  target.write(chunk)
260
- if (!dead && current !== null && atLineStart) draw()
266
+ if (!dead && current !== null && atLineStart && holds === 0) draw()
261
267
  },
262
268
  setStatus(line, o = {}) {
263
269
  set([line], o)
@@ -280,6 +286,23 @@ export function createOutputWriter(
280
286
  shownRows = 0
281
287
  }
282
288
  },
289
+ hold() {
290
+ if (!enabled || dead) return () => undefined
291
+ holds++
292
+ cancelTrailing()
293
+ if (shown) {
294
+ stream.write(eraseSeq())
295
+ shown = false
296
+ shownRows = 0
297
+ }
298
+ let released = false
299
+ return () => {
300
+ if (released) return
301
+ released = true
302
+ holds--
303
+ if (holds === 0 && !dead && current !== null && atLineStart) draw()
304
+ }
305
+ },
283
306
  }
284
307
  }
285
308
 
@@ -310,6 +333,8 @@ export interface StatusRegionState {
310
333
  blocked?: readonly (BlockedTask | null)[]
311
334
  /** Running tasks beyond the displayed slots. */
312
335
  overflow: number
336
+ /** The run was stopped: running rows say `aborting`, idle rows leave. */
337
+ stopping?: boolean
313
338
  /**
314
339
  * vx's own steps still running, shown below the worker rows: no worker
315
340
  * slot or `--concurrency` bounds them (M, 2026-10-15).
@@ -428,6 +453,7 @@ export function formatStatusRegion(
428
453
  // One task running has no path to compare against.
429
454
  const busy = s.slots.filter((x) => x !== null).length
430
455
  for (const [row, slot] of s.slots.entries()) {
456
+ if (slot === null && s.stopping === true) continue
431
457
  if (slot === null) {
432
458
  const w = s.blocked?.[row]
433
459
  lines.push(
@@ -440,6 +466,21 @@ export function formatStatusRegion(
440
466
  }
441
467
  // What waits on it, after the id: the row to watch is the one the
442
468
  // longest chain waits on, and the one the most tasks wait on.
469
+ if (s.stopping === true) {
470
+ lines.push(
471
+ formatTaskRow(
472
+ ' ',
473
+ Math.max(0, s.nowMs - slot.startedMs),
474
+ 'aborting',
475
+ WARN,
476
+ '',
477
+ '',
478
+ paintPinnedId(slot.id, colors),
479
+ colors,
480
+ ),
481
+ )
482
+ continue
483
+ }
443
484
  const waiting = s.blocks?.get(slot.id) ?? 0
444
485
  const notes: string[] = []
445
486
  if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
@@ -81,9 +81,9 @@ export interface SummaryStats {
81
81
  /** Ended OK — executed successes AND cache hits. */
82
82
  successful: number
83
83
  skipped: number
84
- /** Killed by a shutdown signal (final summary only). Not in `total`. */
84
+ /** Killed by a shutdown signal, shown as `aborted`. Not in `total`. */
85
85
  aborted?: number
86
- /** Reached by the stop before they ran (final summary only). Not in `total`. */
86
+ /** Reached by the stop before they ran. Not in `total`. */
87
87
  notRun?: number
88
88
  total: number
89
89
  upToDate: number
@@ -183,9 +183,13 @@ export interface RunContext {
183
183
  version: string
184
184
  /** Projects with a real task in the graph — the "affected" half of the bar. */
185
185
  packageCount: number
186
+ /** The run's whole id, as `vx last` takes it; shown on the `info` row. */
187
+ runId?: string | undefined
186
188
  /** Worker-pool size for this run; shown on the `info` row. */
187
189
  concurrency?: number
188
190
  remoteCacheEnabled: boolean
191
+ /** The run's cache scope, named after the cache mode: `scope vznjs/vx:feat-x`. */
192
+ cacheScope?: string | undefined
189
193
  /**
190
194
  * The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
191
195
  * remote 3↓ 4↑`. Absent: the plain `N miss` legend.
@@ -405,6 +409,7 @@ export function formatSummarySection(
405
409
  if (context.concurrency !== undefined)
406
410
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
407
411
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
412
+ if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
408
413
  // The plugin's hand on the run: a task a policy held with a worker
409
414
  // free would otherwise have started; without the line nothing says
410
415
  // the policy acted. The duration is the waits summed, task-seconds
@@ -415,6 +420,7 @@ export function formatSummarySection(
415
420
  `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
416
421
  )
417
422
  }
423
+ if (context.runId !== undefined) info.push(`run ${context.runId}`)
418
424
  lines.push('', row('info', join(info)), timeRow)
419
425
  } else {
420
426
  lines.push('', timeRow)
package/src/settings.ts CHANGED
@@ -43,6 +43,12 @@ export interface Setting {
43
43
  const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
44
44
 
45
45
  export const SETTINGS: readonly Setting[] = [
46
+ {
47
+ path: 'baseBranch',
48
+ kind: { type: 'string' },
49
+ default: "origin/HEAD's branch, then main, then master",
50
+ doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
51
+ },
46
52
  {
47
53
  path: 'run.concurrency',
48
54
  kind: { type: 'concurrency' },
@@ -57,12 +63,6 @@ export const SETTINGS: readonly Setting[] = [
57
63
  doc: 'Which ready task starts first when there are more than workers.',
58
64
  flag: { verb: 'run', name: '--schedule' },
59
65
  },
60
- {
61
- path: 'run.affectedBase',
62
- kind: { type: 'string' },
63
- default: 'origin/HEAD, then the trunk, else HEAD~1',
64
- doc: 'The git ref a bare `--affected` compares with.',
65
- },
66
66
  {
67
67
  path: 'run.branch',
68
68
  kind: { type: 'string' },
@@ -137,9 +137,9 @@ export const SETTINGS: readonly Setting[] = [
137
137
  {
138
138
  path: 'cache.scope',
139
139
  kind: { type: 'string' },
140
- default: 'none (the default keys)',
141
- doc: "Where cache writes land (`'pr-123'`): reads try the default key, then the scope's.",
142
- flag: { verb: 'run', name: '--cache-scope' },
140
+ default: "the branch's, `<owner>/<repo>:<branch>`, with a remote cache off `baseBranch`",
141
+ doc: "A branch's remote entries (`'vznjs/vx:feat-x'`): tasks the branch changed read the scope's key, the rest the default key; the local cache never uses it. Empty: no scope.",
142
+ config: false,
143
143
  },
144
144
  {
145
145
  path: 'cache.download',
@@ -220,7 +220,7 @@ export const SETTINGS: readonly Setting[] = [
220
220
  },
221
221
  ]
222
222
 
223
- /** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
223
+ /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
224
224
  export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
225
225
 
226
226
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
@@ -229,7 +229,7 @@ export function setting(path: string): Setting {
229
229
  return BY_PATH.get(path)!
230
230
  }
231
231
 
232
- /** `run.affectedBase` → `VX_RUN_AFFECTED_BASE`. */
232
+ /** `run.branch` → `VX_RUN_BRANCH`. */
233
233
  export function envName(path: string): string {
234
234
  return `VX_${path
235
235
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
@@ -97,7 +97,7 @@ export function wantsJson(argv: readonly string[]): boolean {
97
97
  /**
98
98
  * The JSON a verb that answers JSON prints for a refusal: one line. A
99
99
  * core code (`VX_E_…`) links its section of cli.md, which says the fix;
100
- * `vx docs <code>` prints the same section offline. A plugin's code has none.
100
+ * `vx docs <code>` prints the same section. A plugin's code has none.
101
101
  */
102
102
  export function errorDocument(code: string, message: string): string {
103
103
  const docs = code.startsWith('VX_E_')
@@ -0,0 +1,74 @@
1
+ // A secret asked for on the terminal: hidden, one line, only where someone can answer.
2
+
3
+ /** Where a missing secret is asked for. */
4
+ export interface SecretTerminal {
5
+ /** stdin and stderr are both terminals: someone can answer. */
6
+ interactive(): boolean
7
+ /** Ask `question` on stderr with the answer hidden; resolves the line typed. */
8
+ ask(question: string): Promise<string>
9
+ }
10
+
11
+ interface HiddenInput {
12
+ isRaw?: boolean
13
+ setRawMode(mode: boolean): unknown
14
+ on(event: 'data', fn: (chunk: Buffer | string) => void): unknown
15
+ off(event: 'data', fn: (chunk: Buffer | string) => void): unknown
16
+ resume(): unknown
17
+ pause(): unknown
18
+ }
19
+
20
+ /**
21
+ * Read one line from a raw-mode terminal, echoing nothing; backspace edits.
22
+ * Ctrl-D answers nothing. Raw mode delivers no SIGINT, so Ctrl-C answers
23
+ * nothing and raises it: the run stops as it would at any other moment.
24
+ */
25
+ function askHidden(
26
+ question: string,
27
+ input: HiddenInput,
28
+ output: { write(s: string): unknown },
29
+ interrupt: () => void,
30
+ ): Promise<string> {
31
+ output.write(question)
32
+ const wasRaw = input.isRaw === true
33
+ input.setRawMode(true)
34
+ input.resume()
35
+ return new Promise((resolve) => {
36
+ let line = ''
37
+ const done = () => {
38
+ input.off('data', onData)
39
+ input.setRawMode(wasRaw)
40
+ input.pause()
41
+ output.write('\n')
42
+ }
43
+ const onData = (chunk: Buffer | string) => {
44
+ for (const ch of String(chunk)) {
45
+ if (ch === '\r' || ch === '\n') {
46
+ done()
47
+ resolve(line)
48
+ return
49
+ }
50
+ if (ch === '\u0003' || ch === '\u0004') {
51
+ done()
52
+ resolve('')
53
+ if (ch === '\u0003') interrupt()
54
+ return
55
+ }
56
+ if (ch === '\u007f' || ch === '\b') line = line.replace(/.$/u, '')
57
+ else if (ch >= ' ') line += ch
58
+ }
59
+ }
60
+ input.on('data', onData)
61
+ })
62
+ }
63
+
64
+ /** A process's terminal: asked only when its stdin and stderr are both TTYs. */
65
+ export function terminalOf(
66
+ stdin: HiddenInput & { isTTY?: boolean },
67
+ stderr: { isTTY?: boolean; write(s: string): unknown },
68
+ interrupt: () => void = () => process.kill(process.pid, 'SIGINT'),
69
+ ): SecretTerminal {
70
+ return {
71
+ interactive: () => stdin.isTTY === true && stderr.isTTY === true,
72
+ ask: (question) => askHidden(question, stdin, stderr, interrupt),
73
+ }
74
+ }
package/src/util/index.ts CHANGED
@@ -58,9 +58,8 @@ export {
58
58
  GLOB_WILDCARDS,
59
59
  normalizeBunGlob,
60
60
  normalizeGlob,
61
- namesNodeModules,
62
- OUTPUT_NEVER,
63
61
  outputMatcher,
62
+ outputNever,
64
63
  restorableOutput,
65
64
  printable,
66
65
  relPosix,
@@ -74,6 +73,7 @@ export {
74
73
  taskGlob,
75
74
  wholeSubtreePrefixes,
76
75
  slashBraceExpansions,
76
+ braceAlternatives,
77
77
  } from './paths.js'
78
78
  export {
79
79
  claimExitForSignal,
@@ -119,3 +119,13 @@ export {
119
119
  secretNamed,
120
120
  type SecretMask,
121
121
  } from './secret-mask.js'
122
+ export {
123
+ isSecretUnavailable,
124
+ SecretBroker,
125
+ type PluginSecrets,
126
+ type SecretRequest,
127
+ type SecretStore,
128
+ type SecretUnavailable,
129
+ type SecretUnavailableReason,
130
+ } from './secrets.js'
131
+ export { terminalOf, type SecretTerminal } from './hidden-prompt.js'
package/src/util/paths.ts CHANGED
@@ -387,18 +387,36 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
387
387
  }
388
388
 
389
389
  /**
390
- * What no output glob reaches. An output is otherwise taken as written —
391
- * `node_modules/**` IS an install task's output — but no task produces
392
- * these, and a root project declaring `**` would otherwise wipe its
393
- * repository and the cache it is restoring from (2026-09-16). `.vx` whole
394
- * and each `vx.lock.json` are vx's own: as outputs, the clean deleted an
395
- * uncommitted workspace config and a hit restored a lock or ignore file the
396
- * key does not see over the current one.
390
+ * What no output glob reaches, relative to the project: its root's `.git`
391
+ * and `.vx`, and the `vx.lock.json` beside its config. An output is
392
+ * otherwise taken as written — `node_modules/**` IS an install task's
393
+ * output — but a root project declaring `**` would otherwise wipe its
394
+ * repository and the cache it is restoring from (2026-09-16), the clean
395
+ * deleted an uncommitted workspace config, and a hit restored a lock or
396
+ * ignore file the key does not see over the current one. Only where vx
397
+ * writes them: reserved at any depth, a hit dropped a build's own
398
+ * `dist/.git/**` and `dist/sub/vx.lock.json` (2026-10-17).
397
399
  */
398
- export const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
400
+ const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
401
+
402
+ /**
403
+ * `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
404
+ * those globs cross into other projects, and every project's `vx.lock.json`
405
+ * is vx's.
406
+ */
407
+ const WORKSPACE_OUTPUT_NEVER = [...OUTPUT_NEVER, '**/vx.lock.json']
408
+
409
+ /**
410
+ * What `globs` may not reach in `scope`: its never-list, and every
411
+ * `node_modules` unless a glob names one.
412
+ */
413
+ export function outputNever(globs: readonly string[], scope: 'project' | 'workspace'): string[] {
414
+ const never = scope === 'project' ? OUTPUT_NEVER : WORKSPACE_OUTPUT_NEVER
415
+ return namesNodeModules(globs) ? never : [...never, '**/node_modules/**']
416
+ }
399
417
 
400
418
  /** Whether a positive glob in `globs` names a `node_modules` directory. */
401
- export function namesNodeModules(globs: readonly string[]): boolean {
419
+ function namesNodeModules(globs: readonly string[]): boolean {
402
420
  return globs.some(
403
421
  (g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
404
422
  )
@@ -411,11 +429,12 @@ export function namesNodeModules(globs: readonly string[]): boolean {
411
429
  * artifact naming anything else was not written by this task's save, and
412
430
  * none of it is restored (2026-10-16).
413
431
  */
414
- export function restorableOutput(globs: readonly string[]): (rel: string) => boolean {
432
+ export function restorableOutput(
433
+ globs: readonly string[],
434
+ scope: 'project' | 'workspace' = 'project',
435
+ ): (rel: string) => boolean {
415
436
  const isOutput = outputMatcher(globs)
416
- const never = anyTaskGlob(
417
- namesNodeModules(globs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**'],
418
- )
437
+ const never = anyTaskGlob(outputNever(globs, scope))
419
438
  return (rel) => isOutput(rel) && !never(rel)
420
439
  }
421
440
 
@@ -430,7 +449,7 @@ export function slashBraceExpansions(pattern: string): string[] {
430
449
  }
431
450
 
432
451
  /** `pattern` with every brace group of two or more alternatives expanded. */
433
- function braceAlternatives(pattern: string): string[] {
452
+ export function braceAlternatives(pattern: string): string[] {
434
453
  return expandBraces(pattern, false)
435
454
  }
436
455
 
@@ -0,0 +1,150 @@
1
+ // A plugin's secrets, kept in the OS keychain (`Bun.secrets`) under the
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
4
+ // environment here: a variable, an option or a config file is the
5
+ // plugin's to check first, so the keychain is always the last source.
6
+
7
+ import { type SecretTerminal, terminalOf } from './hidden-prompt.js'
8
+
9
+ /** What `ctx.secret` is asked: the entry's name, and the question to ask on a terminal. */
10
+ export interface SecretRequest {
11
+ /** The entry's name within the plugin's keychain service: one per remote, say. */
12
+ readonly key: string
13
+ /** Asked when the keychain lacks the entry. Omitted: never asked, a miss is `not-found`. */
14
+ readonly prompt?: string
15
+ }
16
+
17
+ /** A plugin's keychain, on the contexts of the hooks that run before the first task. */
18
+ export interface PluginSecrets {
19
+ /**
20
+ * The entry, else the terminal's answer (then saved), else a rejection a
21
+ * `isSecretUnavailable` guard names. Once per run per key: concurrent asks
22
+ * share the answer.
23
+ */
24
+ secret(req: SecretRequest): Promise<string>
25
+ /** Delete the entry (a server refused it), so the next run asks again. Never throws. */
26
+ forgetSecret(req: { readonly key: string }): Promise<void>
27
+ }
28
+
29
+ /**
30
+ * Why `secret` had nothing: the keychain lacks it and no prompt was given
31
+ * (`not-found`), a prompt was given and nobody can answer — CI, no TTY, or
32
+ * the status line already up (`no-terminal`), the answer was empty
33
+ * (`declined`), or there is no keychain and no answer (`store-unavailable`).
34
+ */
35
+ export type SecretUnavailableReason = 'not-found' | 'no-terminal' | 'declined' | 'store-unavailable'
36
+
37
+ export interface SecretUnavailable extends Error {
38
+ readonly reason: SecretUnavailableReason
39
+ }
40
+
41
+ const NAME = 'SecretUnavailable'
42
+
43
+ /** A `secret` rejection that is a missing secret, not a fault. By name: a plugin may load its own copy of core. */
44
+ export function isSecretUnavailable(err: unknown): err is SecretUnavailable {
45
+ return err instanceof Error && err.name === NAME && 'reason' in err
46
+ }
47
+
48
+ function unavailable(key: string, reason: SecretUnavailableReason): SecretUnavailable {
49
+ return Object.assign(new Error(`secret '${key}' unavailable: ${reason}`), { name: NAME, reason })
50
+ }
51
+
52
+ /** The keychain as the broker reads and writes it: `Bun.secrets`, or a test's stand-in. */
53
+ export interface SecretStore {
54
+ get(o: { service: string; name: string }): Promise<string | null>
55
+ set(o: { service: string; name: string; value: string }): Promise<void>
56
+ delete(o: { service: string; name: string }): Promise<boolean>
57
+ }
58
+
59
+ // Reached only when asked: touching `process.stdin` opens the stream.
60
+ const processTerminal: SecretTerminal = {
61
+ interactive: () => terminalOf(process.stdin, process.stderr).interactive(),
62
+ ask: (question) => terminalOf(process.stdin, process.stderr).ask(question),
63
+ }
64
+
65
+ /**
66
+ * One run's secrets for every plugin. A prompt waits for the one before
67
+ * it, so two plugins never ask at once, and none is asked once
68
+ * `closePrompts` says the status line is up.
69
+ */
70
+ export class SecretBroker {
71
+ private readonly memo = new Map<string, Promise<string>>()
72
+ private prompting: Promise<unknown> = Promise.resolve()
73
+ private closed = false
74
+
75
+ constructor(
76
+ private readonly ci: boolean,
77
+ private readonly warn: (message: string) => void,
78
+ private readonly store: SecretStore = Bun.secrets,
79
+ private readonly terminal: SecretTerminal = processTerminal,
80
+ /** Quiets the terminal's live region until the release it returns. */
81
+ private readonly quiet: () => () => void = () => () => undefined,
82
+ ) {}
83
+
84
+ /** `plugin`'s keychain: its entries live under its package name. */
85
+ for(plugin: string): PluginSecrets {
86
+ return {
87
+ secret: (req) => {
88
+ const memoKey = `${plugin}\0${req.key}`
89
+ let pending = this.memo.get(memoKey)
90
+ if (pending === undefined) {
91
+ pending = this.resolve(plugin, req)
92
+ this.memo.set(memoKey, pending)
93
+ }
94
+ return pending
95
+ },
96
+ forgetSecret: async ({ key }) => {
97
+ this.memo.delete(`${plugin}\0${key}`)
98
+ try {
99
+ await this.store.delete({ service: plugin, name: key })
100
+ } catch {
101
+ // no keychain to delete from
102
+ }
103
+ },
104
+ }
105
+ }
106
+
107
+ /** The status line is about to start: from here a prompt would be drawn over. */
108
+ closePrompts(): void {
109
+ this.closed = true
110
+ }
111
+
112
+ private async resolve(service: string, req: SecretRequest): Promise<string> {
113
+ const entry = { service, name: req.key }
114
+ let storeDown = false
115
+ try {
116
+ const stored = await this.store.get(entry)
117
+ if (stored) return stored
118
+ } catch {
119
+ // No keychain (headless Linux without libsecret).
120
+ storeDown = true
121
+ }
122
+ if (req.prompt === undefined)
123
+ throw unavailable(req.key, storeDown ? 'store-unavailable' : 'not-found')
124
+ const asked = this.prompting.then(() => this.ask(req.key, req.prompt!))
125
+ this.prompting = asked.catch(() => undefined)
126
+ const answer = await asked
127
+ try {
128
+ await this.store.set({ ...entry, value: answer })
129
+ } catch (err) {
130
+ this.warn(
131
+ `[vx] could not save '${req.key}' to the OS keychain (${err instanceof Error ? err.message : String(err)}); used for this run only`,
132
+ )
133
+ }
134
+ return answer
135
+ }
136
+
137
+ private async ask(key: string, prompt: string): Promise<string> {
138
+ if (this.ci || this.closed || !this.terminal.interactive())
139
+ throw unavailable(key, 'no-terminal')
140
+ const release = this.quiet()
141
+ let answer: string
142
+ try {
143
+ answer = (await this.terminal.ask(prompt)).trim()
144
+ } finally {
145
+ release()
146
+ }
147
+ if (answer === '') throw unavailable(key, 'declined')
148
+ return answer
149
+ }
150
+ }