@vzn/vx 0.0.640 → 0.0.642
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/launcher.sh +9 -2
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +735 -107
- package/src/cache/cache.ts +133 -29
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +9 -2
- package/src/cache/inputs.ts +244 -108
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +9 -7
- package/src/cache/layered-cache.ts +74 -9
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +11 -3
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +11 -5
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/handoff.ts +5 -0
- package/src/history/history.ts +43 -33
- package/src/index.ts +11 -1
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +111 -29
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +35 -5
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +62 -38
- 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 +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +58 -15
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +11 -6
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +13 -0
- package/src/util/paths.ts +58 -5
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +6 -4
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2352
- package/docs/cli.md +0 -3280
- package/docs/execution.md +0 -675
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2461
- package/docs/security.md +0 -252
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
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
|
|
@@ -110,7 +110,7 @@ export interface SummaryStats {
|
|
|
110
110
|
savedMs?: number
|
|
111
111
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
112
112
|
emptyGroups?: readonly string[]
|
|
113
|
-
/** Each finished task's cache bucket by its key, so its remote traffic can move it. */
|
|
113
|
+
/** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
114
114
|
buckets?: ReadonlyMap<string, CacheBucket>
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -183,6 +183,8 @@ 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
|
|
@@ -415,6 +417,7 @@ export function formatSummarySection(
|
|
|
415
417
|
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
|
|
416
418
|
)
|
|
417
419
|
}
|
|
420
|
+
if (context.runId !== undefined) info.push(`run ${context.runId}`)
|
|
418
421
|
lines.push('', row('info', join(info)), timeRow)
|
|
419
422
|
} else {
|
|
420
423
|
lines.push('', timeRow)
|
|
@@ -536,12 +539,14 @@ export function formatRunSummary(
|
|
|
536
539
|
)
|
|
537
540
|
}
|
|
538
541
|
|
|
539
|
-
/** Each finished task's cache bucket by its key. */
|
|
542
|
+
/** Each finished task's cache bucket by its key and by its id. */
|
|
540
543
|
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
|
|
541
544
|
const out = new Map<string, CacheBucket>()
|
|
542
545
|
for (const o of outcomes) {
|
|
543
|
-
const bucket =
|
|
544
|
-
if (bucket
|
|
546
|
+
const bucket = cacheBucketOf(o)
|
|
547
|
+
if (bucket === undefined) continue
|
|
548
|
+
if (o.hash !== undefined) out.set(o.hash, bucket)
|
|
549
|
+
out.set(o.node.id, bucket)
|
|
545
550
|
}
|
|
546
551
|
return out
|
|
547
552
|
}
|
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
|
@@ -59,6 +59,8 @@ export {
|
|
|
59
59
|
normalizeBunGlob,
|
|
60
60
|
normalizeGlob,
|
|
61
61
|
outputMatcher,
|
|
62
|
+
outputNever,
|
|
63
|
+
restorableOutput,
|
|
62
64
|
printable,
|
|
63
65
|
relPosix,
|
|
64
66
|
DOT_VX_GITATTRIBUTES,
|
|
@@ -71,6 +73,7 @@ export {
|
|
|
71
73
|
taskGlob,
|
|
72
74
|
wholeSubtreePrefixes,
|
|
73
75
|
slashBraceExpansions,
|
|
76
|
+
braceAlternatives,
|
|
74
77
|
} from './paths.js'
|
|
75
78
|
export {
|
|
76
79
|
claimExitForSignal,
|
|
@@ -116,3 +119,13 @@ export {
|
|
|
116
119
|
secretNamed,
|
|
117
120
|
type SecretMask,
|
|
118
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
|
@@ -12,11 +12,12 @@ export function toPosix(p: string): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`
|
|
16
|
-
* else in `.vx`, so the workspace
|
|
17
|
-
* committed. `vx init` and the cache
|
|
15
|
+
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`, `.vx/tmp`
|
|
16
|
+
* (where restores stage) and nothing else in `.vx`, so the workspace
|
|
17
|
+
* config and the lock beside it are committed. `vx init` and the cache
|
|
18
|
+
* write it when missing.
|
|
18
19
|
*/
|
|
19
|
-
export const DOT_VX_GITIGNORE = '/cache\n'
|
|
20
|
+
export const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
|
|
20
21
|
|
|
21
22
|
/** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
|
|
22
23
|
export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
|
|
@@ -385,6 +386,58 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
385
386
|
return (rel) => pos(rel) && !neg(rel)
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
/**
|
|
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).
|
|
399
|
+
*/
|
|
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
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Whether a positive glob in `globs` names a `node_modules` directory. */
|
|
419
|
+
function namesNodeModules(globs: readonly string[]): boolean {
|
|
420
|
+
return globs.some(
|
|
421
|
+
(g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Whether a restore may write `rel`: one of the outputs `globs` declare,
|
|
427
|
+
* never under `OUTPUT_NEVER`, and under `node_modules` only where a glob
|
|
428
|
+
* names it — what the save side could have packed (`resolveOutputs`). An
|
|
429
|
+
* artifact naming anything else was not written by this task's save, and
|
|
430
|
+
* none of it is restored (2026-10-16).
|
|
431
|
+
*/
|
|
432
|
+
export function restorableOutput(
|
|
433
|
+
globs: readonly string[],
|
|
434
|
+
scope: 'project' | 'workspace' = 'project',
|
|
435
|
+
): (rel: string) => boolean {
|
|
436
|
+
const isOutput = outputMatcher(globs)
|
|
437
|
+
const never = anyTaskGlob(outputNever(globs, scope))
|
|
438
|
+
return (rel) => isOutput(rel) && !never(rel)
|
|
439
|
+
}
|
|
440
|
+
|
|
388
441
|
/**
|
|
389
442
|
* `pattern` with its first brace group expanded, recursively, while that
|
|
390
443
|
* group holds a `/`; any other pattern as itself, since `Bun.Glob`
|
|
@@ -396,7 +449,7 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
396
449
|
}
|
|
397
450
|
|
|
398
451
|
/** `pattern` with every brace group of two or more alternatives expanded. */
|
|
399
|
-
function braceAlternatives(pattern: string): string[] {
|
|
452
|
+
export function braceAlternatives(pattern: string): string[] {
|
|
400
453
|
return expandBraces(pattern, false)
|
|
401
454
|
}
|
|
402
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
|
+
}
|
|
@@ -122,7 +122,10 @@ export interface FingerprintClaims {
|
|
|
122
122
|
file: string
|
|
123
123
|
before: Uint8Array | null
|
|
124
124
|
after: Uint8Array | null
|
|
125
|
+
extras?: ReadonlyMap<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
125
126
|
}): Promise<ReadonlySet<string> | undefined>
|
|
127
|
+
/** The files a claimed file names that its claimant keys too; absent = it reads none. */
|
|
128
|
+
extraFiles?(file: string, bytes: Uint8Array): readonly string[] | undefined
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
/**
|
|
@@ -303,32 +306,79 @@ export async function affectedChanges(
|
|
|
303
306
|
let claims: FingerprintClaims | undefined
|
|
304
307
|
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
305
308
|
(claims ??= await args.fingerprintClaims?.())
|
|
306
|
-
// A
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
309
|
+
// A file a claimed lockfile names that its claimant keys but the
|
|
310
|
+
// lockfile does not pin (`FingerprintClaim.extraFiles`: bun.lock's
|
|
311
|
+
// patches, a `file:`, `link:` or `portal:` directory
|
|
312
|
+
// a manager copies or links) is part of
|
|
313
|
+
// that lockfile: a change to one, or under one, is a lockfile change the
|
|
314
|
+
// claimant answers with it. Only a path no project below the root owns
|
|
315
|
+
// can be one, so a diff inside the projects never loads the claims.
|
|
316
|
+
const extrasBy = new Map<
|
|
317
|
+
string,
|
|
318
|
+
Map<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
319
|
+
>()
|
|
320
|
+
const rootDir = path.resolve(args.workspaceRoot)
|
|
321
|
+
const loose = changed.filter((rel) => {
|
|
322
|
+
const abs = path.join(rootDir, rel)
|
|
323
|
+
return !args.projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
|
|
324
|
+
})
|
|
325
|
+
const extraClaims = loose.length > 0 ? await loadClaims() : undefined
|
|
326
|
+
for (const file of extraClaims?.files ?? []) {
|
|
327
|
+
const after = await bytesOrNull(path.join(args.workspaceRoot, file))
|
|
328
|
+
const before = await gitBytesAt(args.workspaceRoot, base, file)
|
|
329
|
+
const namedAfter = after === null ? [] : extraClaims!.extraFiles?.(file, after)
|
|
330
|
+
const named = new Set([
|
|
331
|
+
...(before === null ? [] : (extraClaims!.extraFiles?.(file, before) ?? [])),
|
|
332
|
+
...(namedAfter ?? []),
|
|
333
|
+
])
|
|
334
|
+
const extras = new Map<string, { before: Uint8Array | null; after: Uint8Array | null }>()
|
|
335
|
+
for (const rel of named) {
|
|
336
|
+
const at = path.posix.normalize(rel).replace(/\/+$/, '')
|
|
337
|
+
if (loose.includes(at)) {
|
|
338
|
+
extras.set(rel, {
|
|
339
|
+
before: await gitBytesAt(args.workspaceRoot, base, at),
|
|
340
|
+
after: await bytesOrNull(path.join(args.workspaceRoot, at)),
|
|
341
|
+
})
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
// A directory's sides are not its bytes: a file under it changed,
|
|
345
|
+
// so they differ, and only the projects reaching it move.
|
|
346
|
+
const under = loose.filter((p) => p.startsWith(`${at}/`))
|
|
347
|
+
if (under.length > 0) {
|
|
348
|
+
extras.set(rel, { before: null, after: new TextEncoder().encode(under.join('\0')) })
|
|
317
349
|
}
|
|
318
350
|
}
|
|
351
|
+
if (extras.size > 0) extrasBy.set(file, extras)
|
|
352
|
+
else if (file === 'bun.lock' && after !== null && namedAfter === undefined) {
|
|
353
|
+
// A bun.lock claimant that names no file still folds the patches
|
|
354
|
+
// bun.lock names (the lockfile records no hash of them): it cannot
|
|
355
|
+
// say which projects one reaches, so its edit widens.
|
|
356
|
+
const patches = new Set(bunPatchFiles(after))
|
|
357
|
+
const patch = loose.find((p) => patches.has(p))
|
|
358
|
+
if (patch !== undefined) return everything(patch)
|
|
359
|
+
}
|
|
319
360
|
}
|
|
320
361
|
const claimedOwned = new Map<string, string>()
|
|
321
362
|
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
363
|
+
for (const file of extrasBy.keys()) {
|
|
364
|
+
if (!rootNamesChanged.includes(file)) rootNamesChanged.push(file)
|
|
365
|
+
}
|
|
322
366
|
const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
|
|
323
367
|
for (const file of rootNamesChanged) {
|
|
324
368
|
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
369
|
+
const extras = extrasBy.get(file)
|
|
325
370
|
const answer = await claimed.affected({
|
|
326
371
|
file,
|
|
327
372
|
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
328
373
|
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
374
|
+
...(extras !== undefined ? { extras } : {}),
|
|
329
375
|
})
|
|
330
|
-
|
|
331
|
-
|
|
376
|
+
const why =
|
|
377
|
+
extras === undefined || changed.includes(file)
|
|
378
|
+
? file
|
|
379
|
+
: path.posix.normalize([...extras.keys()][0]!).replace(/\/+$/, '')
|
|
380
|
+
if (answer === undefined) return everything(why)
|
|
381
|
+
for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, why)
|
|
332
382
|
}
|
|
333
383
|
|
|
334
384
|
const realDirs = new Map(
|
|
@@ -103,7 +103,7 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
|
103
103
|
throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
// Bun's resolver reads \\ as a separator, so a config under a\\b is served
|
|
106
|
+
// Bun's resolver reads \\ as a separator and ? as a query, so a config under a\\b or a?b is served
|
|
107
107
|
// from the path as written (project-loader.ts's LITERAL_QUERY), read now.
|
|
108
108
|
let literal = false
|
|
109
109
|
const serveLiteral = () => {
|
|
@@ -172,7 +172,7 @@ self.onmessage = async (e) => {
|
|
|
172
172
|
if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
|
|
173
173
|
}
|
|
174
174
|
try {
|
|
175
|
-
const literalPath = path
|
|
175
|
+
const literalPath = /[\\\\?]/.test(path)
|
|
176
176
|
if (literalPath) serveLiteral()
|
|
177
177
|
const ns = await import(literalPath ? path + '?vx-literal' : path)
|
|
178
178
|
// Awaited as the in-process load's async return flattens it: a Promise
|
|
@@ -378,16 +378,21 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
if (plug.fingerprint !== undefined) {
|
|
381
|
-
const claim = plug.fingerprint as {
|
|
381
|
+
const claim = plug.fingerprint as {
|
|
382
|
+
files?: unknown
|
|
383
|
+
affected?: unknown
|
|
384
|
+
extraFiles?: unknown
|
|
385
|
+
} | null
|
|
382
386
|
if (
|
|
383
387
|
claim === null ||
|
|
384
388
|
typeof claim !== 'object' ||
|
|
385
389
|
!Array.isArray(claim.files) ||
|
|
386
390
|
claim.files.length === 0 ||
|
|
387
|
-
typeof claim.affected !== 'function'
|
|
391
|
+
typeof claim.affected !== 'function' ||
|
|
392
|
+
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
|
|
388
393
|
) {
|
|
389
394
|
throw new UserError(
|
|
390
|
-
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function }`,
|
|
395
|
+
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
|
|
391
396
|
)
|
|
392
397
|
}
|
|
393
398
|
for (const file of claim.files as unknown[]) {
|
|
@@ -770,6 +775,7 @@ export function validateProjectConfig(
|
|
|
770
775
|
}
|
|
771
776
|
assertPosixGlob(g, `${where}.inputs.files`, 'project')
|
|
772
777
|
assertNoForeignToken(g, `${where}.inputs.files`)
|
|
778
|
+
assertInputGlob(g, `${where}.inputs.files`)
|
|
773
779
|
if (namesDirItself(g)) {
|
|
774
780
|
throw new UserError(
|
|
775
781
|
`${where}.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
|
|
@@ -1333,6 +1339,33 @@ function assertPosixGlob(glob: string, where: string, base: string): void {
|
|
|
1333
1339
|
)
|
|
1334
1340
|
}
|
|
1335
1341
|
|
|
1342
|
+
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* What every run would refuse, or silently key nothing for, refused at
|
|
1346
|
+
* `vx lock` instead. `Bun.Glob` reads `{b}` as a brace of one alternative,
|
|
1347
|
+
* so `src/{b}.ts` matches `src/b.ts` and never a file named `{b}.ts`:
|
|
1348
|
+
* neither reading is sure to be the one meant, so both spellings are
|
|
1349
|
+
* named. A path under `.git` is never listed by git, so it keyed nothing.
|
|
1350
|
+
*/
|
|
1351
|
+
function assertInputGlob(glob: string, where: string): void {
|
|
1352
|
+
const m = ONE_ALTERNATIVE_BRACE.exec(glob)
|
|
1353
|
+
if (m !== null) {
|
|
1354
|
+
const inner = m[0].slice(1, -1)
|
|
1355
|
+
throw new UserError(
|
|
1356
|
+
`${where}: "${glob}" holds a brace with one alternative, which matches ` +
|
|
1357
|
+
`"${inner}" and never a name holding "${m[0]}". Write "${glob.replace(m[0], inner)}", ` +
|
|
1358
|
+
`or "${glob.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
if (glob.replace(/^!/, '').split('/').includes('.git')) {
|
|
1362
|
+
throw new UserError(
|
|
1363
|
+
`${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
|
|
1364
|
+
`inputs are the repository's files`,
|
|
1365
|
+
)
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1336
1369
|
/**
|
|
1337
1370
|
* `!!x` INVERTS the input set instead of double-negating it.
|
|
1338
1371
|
*
|
|
@@ -1565,6 +1598,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
|
|
|
1565
1598
|
}
|
|
1566
1599
|
assertPosixGlob(g, where, 'workspace-root')
|
|
1567
1600
|
assertNoForeignToken(g, where)
|
|
1601
|
+
if (negation) assertInputGlob(g, where)
|
|
1568
1602
|
if (namesDirItself(g)) {
|
|
1569
1603
|
throw new UserError(
|
|
1570
1604
|
`${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
|
|
@@ -75,8 +75,9 @@ let serving = false
|
|
|
75
75
|
const HELD_QUERY = /\?vx-(?:held|literal)=([0-9a-f]+)$/
|
|
76
76
|
|
|
77
77
|
// Bun's resolver reads `\` as a separator, even in a file: URL, so a
|
|
78
|
-
// config under `a\b` was looked for under `a/b
|
|
79
|
-
//
|
|
78
|
+
// config under `a\b` was looked for under `a/b`, and `?` as a query, so
|
|
79
|
+
// one under `a?b` was looked for under `a`. Resolving it here keeps the
|
|
80
|
+
// path as written; only the served load can take it.
|
|
80
81
|
const LITERAL_QUERY = /\?vx-literal=[0-9a-f]+$/
|
|
81
82
|
|
|
82
83
|
function serveHeldSources(): void {
|
|
@@ -148,14 +149,14 @@ async function loadDefaultExport(
|
|
|
148
149
|
// URL yet, and a repeat load (the one that could replay an evaluation
|
|
149
150
|
// made under earlier env values) re-evaluates in a worker instead
|
|
150
151
|
// (`loadedConfigs`, item 678).
|
|
151
|
-
const literal = configPath
|
|
152
|
+
const literal = /[\\?]/.test(configPath)
|
|
152
153
|
const source =
|
|
153
154
|
kind === 'Project' || literal
|
|
154
155
|
? servableSource(bytes, /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js')
|
|
155
156
|
: null
|
|
156
157
|
if (literal && source === null) {
|
|
157
158
|
throw new UserError(
|
|
158
|
-
`${kind} config ${configPath} sits under a path that holds a backslash, which Bun loads only as an ES module in UTF-8`,
|
|
159
|
+
`${kind} config ${configPath} sits under a path that holds ${configPath.includes('\\') ? 'a backslash' : 'a question mark'}, which Bun loads only as an ES module in UTF-8`,
|
|
159
160
|
)
|
|
160
161
|
}
|
|
161
162
|
const hash = xxh3hex(bytes)
|
|
@@ -639,6 +640,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
|
|
|
639
640
|
'plugin',
|
|
640
641
|
'randomUUIDv7',
|
|
641
642
|
'resolveSync',
|
|
643
|
+
'secrets',
|
|
642
644
|
'serve',
|
|
643
645
|
'sleep',
|
|
644
646
|
'sleepSync',
|