@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.
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- 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))
|
package/src/report/summary.ts
CHANGED
|
@@ -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
|
|
84
|
+
/** Killed by a shutdown signal, shown as `aborted`. Not in `total`. */
|
|
85
85
|
aborted?: number
|
|
86
|
-
/** Reached by the stop before they ran
|
|
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: '
|
|
141
|
-
doc: "
|
|
142
|
-
|
|
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`
|
|
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.
|
|
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')
|
package/src/util/errors.ts
CHANGED
|
@@ -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
|
|
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
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* and
|
|
395
|
-
* uncommitted workspace config and a hit restored a lock or
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|