@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -0,0 +1,428 @@
1
+ // Every vx setting, once: its path is its `.vx/workspace` key, and its
2
+ // environment variable and flag are derived from that path. Parsing,
3
+ // validation, `vx info`'s settings view and the docs' settings tables all
4
+ // read this list (`tests/settings.test.ts` holds src and docs to it).
5
+
6
+ import {
7
+ machineParallelism,
8
+ MAX_TIMEOUT_MS,
9
+ parseDecimalInt,
10
+ parseDuration,
11
+ parseSize,
12
+ UserError,
13
+ } from './util/index.js'
14
+
15
+ export type SettingKind =
16
+ | { readonly type: 'int'; readonly min: number }
17
+ | { readonly type: 'concurrency' }
18
+ // `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
19
+ | { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
20
+ | { readonly type: 'size' }
21
+ | { readonly type: 'bool' }
22
+ | { readonly type: 'enum'; readonly values: readonly string[] }
23
+ | { readonly type: 'string' }
24
+
25
+ export interface Setting {
26
+ /** The `.vx/workspace` path (`run.timeout`); env and flag derive from it. */
27
+ readonly path: string
28
+ readonly kind: SettingKind
29
+ /** The built-in, as the docs print it. */
30
+ readonly default: string
31
+ /** One line for the docs table. */
32
+ readonly doc: string
33
+ /** The verb whose flag sets it (`vx run --timeout`); none → env and config only. */
34
+ readonly flag?: { readonly verb: 'run' | 'watch'; readonly name: string }
35
+ /** The task field that overrides it, and beats every run-level source. */
36
+ readonly task?: string
37
+ /** False: read before `.vx/workspace` loads, so flag and env only. */
38
+ readonly config?: false
39
+ /** False: `.vx/workspace` only, no variable. */
40
+ readonly env?: false
41
+ }
42
+
43
+ const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
44
+
45
+ export const SETTINGS: readonly Setting[] = [
46
+ {
47
+ path: 'run.concurrency',
48
+ kind: { type: 'concurrency' },
49
+ default: 'the cores this process may use',
50
+ doc: "Tasks at once: a positive integer, or a share of the cores (`'50%'`).",
51
+ flag: { verb: 'run', name: '--concurrency' },
52
+ },
53
+ {
54
+ path: 'run.schedule',
55
+ kind: { type: 'enum', values: SCHEDULES },
56
+ default: 'most-work',
57
+ doc: 'Which ready task starts first when there are more than workers.',
58
+ flag: { verb: 'run', name: '--schedule' },
59
+ },
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
+ {
67
+ path: 'run.branch',
68
+ kind: { type: 'string' },
69
+ default: "git's branch, then the CI provider's",
70
+ doc: 'The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch.',
71
+ flag: { verb: 'run', name: '--branch' },
72
+ config: false,
73
+ },
74
+ {
75
+ path: 'run.timeout',
76
+ kind: { type: 'duration', timer: true },
77
+ default: 'none',
78
+ doc: "A task's time limit, for tasks without their own `exec.timeout` (`'30m'`).",
79
+ flag: { verb: 'run', name: '--timeout' },
80
+ task: 'exec.timeout',
81
+ },
82
+ {
83
+ path: 'run.retries',
84
+ kind: { type: 'int', min: 0 },
85
+ default: '0',
86
+ doc: 'Re-runs of a failed task, for tasks without their own `exec.retries`.',
87
+ flag: { verb: 'run', name: '--retries' },
88
+ task: 'exec.retries',
89
+ },
90
+ {
91
+ path: 'run.continue',
92
+ kind: { type: 'enum', values: ['never', 'deps-ok', 'always'] },
93
+ default: 'deps-ok',
94
+ doc: 'What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too.',
95
+ flag: { verb: 'run', name: '--continue' },
96
+ },
97
+ {
98
+ path: 'run.outputLogs',
99
+ kind: { type: 'enum', values: ['full', 'errors-only', 'hash-only', 'none'] },
100
+ default: '`full` on CI, else by what the run selects',
101
+ doc: 'Which task output the run prints. Chosen before the workspace loads, so flag and env only.',
102
+ flag: { verb: 'run', name: '--output-logs' },
103
+ config: false,
104
+ },
105
+ {
106
+ path: 'run.killGrace',
107
+ kind: { type: 'duration', timer: true },
108
+ default: '2s',
109
+ doc: 'How long a task that ignores SIGTERM gets before SIGKILL.',
110
+ flag: { verb: 'run', name: '--kill-grace' },
111
+ },
112
+ {
113
+ path: 'run.teardownTimeout',
114
+ kind: { type: 'duration', timer: true, zero: true },
115
+ default: '3s',
116
+ doc: "The bound on each plugin's end-of-run flush and teardown.",
117
+ flag: { verb: 'run', name: '--teardown-timeout' },
118
+ },
119
+ {
120
+ path: 'run.readyNotice',
121
+ kind: { type: 'duration', timer: true },
122
+ default: '10s',
123
+ doc: 'How long a persistent task may take to match `readyWhen` before vx says it waits.',
124
+ flag: { verb: 'run', name: '--ready-notice' },
125
+ },
126
+ {
127
+ path: 'run.rules.exclusiveOutputs',
128
+ kind: { type: 'bool' },
129
+ default: 'true',
130
+ doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
131
+ },
132
+ {
133
+ path: 'run.rules.upfrontKeys',
134
+ kind: { type: 'bool' },
135
+ default: 'true',
136
+ doc: "Refuse a task whose inputs can match another task's outputs.",
137
+ },
138
+ {
139
+ path: 'cache.dir',
140
+ kind: { type: 'string' },
141
+ default: '.vx/cache, entries in ~/.vx/<repo>/cache',
142
+ doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
143
+ flag: { verb: 'run', name: '--cache-dir' },
144
+ },
145
+ {
146
+ path: 'cache.retention.maxSize',
147
+ kind: { type: 'size' },
148
+ default: 'none',
149
+ doc: "Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`).",
150
+ },
151
+ {
152
+ path: 'cache.retention.olderThan',
153
+ kind: { type: 'duration' },
154
+ default: 'none',
155
+ doc: "Evict entries unused for this long at each run's end (`'30d'`).",
156
+ },
157
+ {
158
+ path: 'cache.policy',
159
+ kind: { type: 'string' },
160
+ default: 'every layer reads and writes',
161
+ doc: "Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top.",
162
+ flag: { verb: 'run', name: '--cache-policy' },
163
+ },
164
+ {
165
+ path: 'cache.scope',
166
+ kind: { type: 'string' },
167
+ default: 'none (the default keys)',
168
+ doc: "Where cache writes land (`'pr-123'`): reads try the default key, then the scope's.",
169
+ flag: { verb: 'run', name: '--cache-scope' },
170
+ },
171
+ {
172
+ path: 'cache.download',
173
+ kind: { type: 'enum', values: ['all', 'toplevel', 'none'] },
174
+ default: 'all',
175
+ doc: 'Which remote hits land on disk: every one, only the requested tasks, or none.',
176
+ flag: { verb: 'run', name: '--cache-download' },
177
+ },
178
+ {
179
+ path: 'cache.inputStability.strategy',
180
+ kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
181
+ default: 'immutable',
182
+ doc: 'What a write to an input during the command means for the save, for tasks without their own.',
183
+ task: 'cache.inputStability.strategy',
184
+ },
185
+ {
186
+ path: 'cache.inputStability.severity',
187
+ kind: { type: 'enum', values: ['warn', 'error'] },
188
+ default: 'warn',
189
+ doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
190
+ task: 'cache.inputStability.severity',
191
+ },
192
+ {
193
+ path: 'cache.outputs.clean',
194
+ kind: { type: 'bool' },
195
+ default: 'true',
196
+ doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
197
+ task: 'cache.outputs.clean',
198
+ env: false,
199
+ },
200
+ {
201
+ path: 'lock.reviewFiles',
202
+ kind: { type: 'bool' },
203
+ default: 'true',
204
+ doc: "`vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it.",
205
+ },
206
+ {
207
+ path: 'watch.poll',
208
+ kind: { type: 'bool' },
209
+ default: 'false',
210
+ doc: 'Poll for changes from the start instead of the OS watcher.',
211
+ flag: { verb: 'watch', name: '--poll' },
212
+ },
213
+ {
214
+ path: 'config.workerTimeout',
215
+ kind: { type: 'duration', timer: true },
216
+ default: '30s',
217
+ doc: 'How long one config evaluation may take.',
218
+ config: false,
219
+ },
220
+ {
221
+ path: 'debug.timing',
222
+ kind: { type: 'bool' },
223
+ default: 'false',
224
+ doc: "Print the run's stage timings to stderr.",
225
+ config: false,
226
+ },
227
+ {
228
+ path: 'debug.watch',
229
+ kind: { type: 'bool' },
230
+ default: 'false',
231
+ doc: '`vx watch`: say on stderr each time the loop arms, with the projects it watches.',
232
+ config: false,
233
+ },
234
+ {
235
+ path: 'debug.dumpable',
236
+ kind: { type: 'bool' },
237
+ default: 'false',
238
+ doc: 'Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env.',
239
+ config: false,
240
+ },
241
+ ]
242
+
243
+ /** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
244
+ export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
245
+
246
+ const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
247
+
248
+ export function setting(path: string): Setting {
249
+ return BY_PATH.get(path)!
250
+ }
251
+
252
+ /** `run.killGrace` → `VX_RUN_KILL_GRACE`. */
253
+ export function envName(path: string): string {
254
+ return `VX_${path
255
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
256
+ .replace(/\./g, '_')
257
+ .toUpperCase()}`
258
+ }
259
+
260
+ /** What a value of this kind looks like, for an error. */
261
+ export function kindHint(kind: SettingKind): string {
262
+ switch (kind.type) {
263
+ case 'int':
264
+ return kind.min === 0 ? 'a non-negative integer' : `an integer ≥ ${kind.min}`
265
+ case 'concurrency':
266
+ return "a positive integer, or a share of the cores like '50%'"
267
+ case 'duration':
268
+ // A timer past 2^31-1 ms fires at once, so "no limit" is no value.
269
+ return kind.timer === true
270
+ ? "a duration like '500ms', '30s' or '5m', at most 24 days"
271
+ : "a duration like '45s', '12h' or '30d'"
272
+ case 'size':
273
+ return "a size like '500MB' or '10GB'"
274
+ case 'bool':
275
+ return 'true or false'
276
+ case 'enum':
277
+ return `one of ${kind.values.map((v) => `'${v}'`).join(', ')}`
278
+ case 'string':
279
+ return 'a non-blank string'
280
+ }
281
+ }
282
+
283
+ /**
284
+ * A positive decimal integer, or `<n>%` of the cores this process may use
285
+ * (`50%` on eight cores is 4, never below 1; over 100% is allowed for
286
+ * I/O-bound work; a container's CPU quota caps the count the way it caps
287
+ * the default), resolved here so the run sees one number and the summary
288
+ * says what it ran with.
289
+ */
290
+ export function parseConcurrency(v: unknown, cpus = machineParallelism()): number | null {
291
+ if (typeof v !== 'string') return null
292
+ const pct = /^(\d+)%$/.exec(v)
293
+ if (pct) {
294
+ const n = parseDecimalInt(pct[1]!)
295
+ if (n === null || n < 1) return null
296
+ return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
297
+ }
298
+ const n = parseDecimalInt(v)
299
+ return n === null || n < 1 ? null : n
300
+ }
301
+
302
+ /**
303
+ * A setting's value, or null when `raw` is not one. Durations come back in
304
+ * ms, sizes in bytes and a share of the cores as a count; the rest as typed.
305
+ */
306
+ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
307
+ switch (kind.type) {
308
+ case 'int': {
309
+ const n =
310
+ typeof raw === 'number' ? raw : typeof raw === 'string' ? parseDecimalInt(raw) : null
311
+ return n !== null && Number.isSafeInteger(n) && n >= kind.min ? n : null
312
+ }
313
+ case 'concurrency':
314
+ return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
315
+ case 'duration': {
316
+ const n = typeof raw === 'string' ? parseDuration(raw) : null
317
+ if (n === null || (n === 0 && kind.zero !== true)) return null
318
+ return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
319
+ }
320
+ case 'size':
321
+ return typeof raw === 'string' ? parseSize(raw) : null
322
+ case 'bool':
323
+ if (typeof raw === 'boolean') return raw
324
+ return raw === 'true' || raw === '1' ? true : raw === 'false' || raw === '0' ? false : null
325
+ case 'enum':
326
+ return typeof raw === 'string' && kind.values.includes(raw) ? raw : null
327
+ case 'string':
328
+ return typeof raw === 'string' && raw.trim() !== '' ? raw : null
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Why `raw` from `where` (a variable, a flag, a config field) is refused
334
+ * for the setting at `path`; `got` is `raw` as the message quotes it. A
335
+ * bare number for a duration names the unit it lacks, and a timer past the
336
+ * platform's range says it would fire at once rather than never.
337
+ */
338
+ export function settingRefusal(
339
+ path: string,
340
+ where: string,
341
+ raw: unknown,
342
+ got = String(raw),
343
+ ): string {
344
+ const { kind } = setting(path)
345
+ if (kind.type === 'duration') {
346
+ const n =
347
+ typeof raw === 'number'
348
+ ? raw
349
+ : typeof raw === 'string' && /^\d+$/.test(raw)
350
+ ? Number(raw)
351
+ : null
352
+ if (n !== null && Number.isSafeInteger(n) && n > 0) {
353
+ return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
354
+ }
355
+ const ms = typeof raw === 'string' ? parseDuration(raw) : null
356
+ if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
357
+ return (
358
+ `${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
359
+ 'a longer timer fires at once, it does not mean "no limit"' +
360
+ (path === 'run.timeout' ? ' — omit it for no limit' : '')
361
+ )
362
+ }
363
+ }
364
+ return `${where} must be ${kindHint(kind)} (got ${got})`
365
+ }
366
+
367
+ /** A setting's environment value, parsed; undefined when unset or empty. */
368
+ export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
369
+ const { kind, env: read } = setting(path)
370
+ if (read === false) return undefined
371
+ const name = envName(path)
372
+ const raw = env[name]
373
+ if (raw === undefined || raw === '') return undefined
374
+ const v = parseSettingValue(kind, raw)
375
+ if (v === null) throw new UserError(settingRefusal(path, name, raw), 'VX_E_USAGE')
376
+ return v
377
+ }
378
+
379
+ /** Validate every set setting variable once, so a deep reader can trust what it reads. */
380
+ export function checkSettingsEnv(env: NodeJS.ProcessEnv = process.env): void {
381
+ for (const s of SETTINGS) settingEnv(s.path, env)
382
+ }
383
+
384
+ /** Where a resolved setting came from. */
385
+ export type SettingSource = 'flag' | 'env' | 'workspace' | 'default'
386
+
387
+ /** `config` at `path` (`run.timeout` → `config.run?.timeout`). */
388
+ export function atPath(config: unknown, path: string): unknown {
389
+ let v: unknown = config
390
+ for (const k of path.split('.')) {
391
+ if (v === null || typeof v !== 'object') return undefined
392
+ v = (v as Record<string, unknown>)[k]
393
+ }
394
+ return v
395
+ }
396
+
397
+ /**
398
+ * A run-level setting: the flag's value, else the environment's, else the
399
+ * workspace's, parsed; undefined with its default. A task override is
400
+ * applied where the task runs, above all three.
401
+ */
402
+ export function resolveSetting(
403
+ path: string,
404
+ workspace: unknown,
405
+ flag?: unknown,
406
+ env: NodeJS.ProcessEnv = process.env,
407
+ ): { value: unknown; source: SettingSource } {
408
+ if (flag !== undefined) return { value: flag, source: 'flag' }
409
+ const e = settingEnv(path, env)
410
+ if (e !== undefined) return { value: e, source: 'env' }
411
+ const w = setting(path).config === false ? undefined : atPath(workspace, path)
412
+ if (w !== undefined)
413
+ return { value: parseSettingValue(setting(path).kind, w), source: 'workspace' }
414
+ return { value: undefined, source: 'default' }
415
+ }
416
+
417
+ /** `run.rules`, each from its variable, else the workspace; unset ones stay on. */
418
+ export function runRules(workspace: unknown): {
419
+ exclusiveOutputs?: boolean
420
+ upfrontKeys?: boolean
421
+ } {
422
+ const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
423
+ for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
424
+ const { value } = resolveSetting(`run.rules.${rule}`, workspace)
425
+ if (value !== undefined) out[rule] = value as boolean
426
+ }
427
+ return out
428
+ }
@@ -13,6 +13,11 @@ const RESET = '\x1b[0m'
13
13
  const BOLD = '\x1b[1m'
14
14
  const DIM = '\x1b[2m'
15
15
 
16
+ // State hues every report shares with the run summary.
17
+ export const SUCCESS = '#22c55e'
18
+ export const WARN = '#eab308'
19
+ export const ERROR = '#ef4444'
20
+
16
21
  export interface ColorSupport {
17
22
  enabled: boolean
18
23
  }
@@ -7,8 +7,9 @@
7
7
  import { readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
 
10
- // `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`.
11
- const HEAD = /^(\/[^\n]*?\/vx\.config\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
10
+ // `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`,
11
+ // from a project's vx.config or the workspace's .vx/workspace.ts.
12
+ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
12
13
 
13
14
  /**
14
15
  * The message with its file named at `line:col`, relative to `cwd`, and a
@@ -88,3 +89,31 @@ function locate(
88
89
  function escapeRe(s: string): string {
89
90
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
90
91
  }
92
+
93
+ /** A config refusal laid out for `ui.notice`: where, what, the allowed set, the fix, the frame. */
94
+ export interface ConfigRefusal {
95
+ where: string
96
+ problem: string
97
+ allowed?: string
98
+ fix?: string
99
+ frame: string
100
+ }
101
+
102
+ /** `configErrorFrame`'s answer in parts, or undefined when it has none. */
103
+ export function configRefusal(message: string, cwd: string): ConfigRefusal | undefined {
104
+ const framed = configErrorFrame(message, cwd)
105
+ if (framed === undefined) return undefined
106
+ const cut = framed.indexOf('\n\n')
107
+ const line = framed.slice(0, cut)
108
+ const frame = framed.slice(cut + 2)
109
+ const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
110
+ if (at === null) return { where: line, problem: '', frame }
111
+ const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
112
+ return {
113
+ where: at[1]!,
114
+ problem: said[1]!.replace(/^has /, ''),
115
+ ...(said[2] !== undefined ? { allowed: said[2] } : {}),
116
+ ...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
117
+ frame,
118
+ }
119
+ }
@@ -13,6 +13,8 @@ export class UserError extends Error {
13
13
  * does not. `VX_E_REFUSED` is any refusal without a code of its own.
14
14
  */
15
15
  readonly code: string
16
+ /** The refusal laid out for a terminal (`refusal`); the message is the same in one line. */
17
+ parts?: RefusalParts
16
18
  // Two signatures, not an optional parameter: the package-api record
17
19
  // keeps the one-argument form every plugin already calls.
18
20
  constructor(message: string)
@@ -24,9 +26,39 @@ export class UserError extends Error {
24
26
  }
25
27
  }
26
28
 
29
+ /** A refusal in parts, as `ui.notice` draws it: one fact a line, why, and the fix. */
30
+ export interface RefusalParts {
31
+ /** `info`: nothing is wrong, there is just nothing to show yet. Default `error`. */
32
+ readonly tone?: 'info'
33
+ readonly headline: string
34
+ readonly what?: readonly string[]
35
+ readonly why?: string
36
+ readonly fix?: string
37
+ }
38
+
39
+ /**
40
+ * A UserError whose terminal form is `parts`. Its message, what JSON and
41
+ * logs carry, is `message` when given, else the parts in one line.
42
+ */
43
+ export function refusal(
44
+ parts: RefusalParts,
45
+ opts: { code?: string; message?: string } = {},
46
+ ): UserError {
47
+ const message =
48
+ opts.message ??
49
+ [
50
+ [parts.headline, ...(parts.what ?? [])].join(': '),
51
+ ...(parts.why !== undefined ? [parts.why] : []),
52
+ ...(parts.fix !== undefined ? [parts.fix] : []),
53
+ ].join('; ')
54
+ const err = new UserError(message, opts.code ?? 'VX_E_REFUSED')
55
+ err.parts = parts
56
+ return err
57
+ }
58
+
27
59
  // A refusal that opens with the config that caused it: the schema's
28
60
  // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
29
- const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
61
+ const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
30
62
 
31
63
  /**
32
64
  * The stable code of anything `bin.ts` catches. A UserError's own, but
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every task gets these (`exec/env.ts`), so `hideEnvFromTasks` leaves
3
+ * them. Exported so the schema doc's enumeration of it can be PINNED against the
4
+ * real list: a hand-copied allowlist is how the two drift, and a reader
5
+ * asking "what does my build script actually see?" is asking a
6
+ * security-shaped question that a stale list answers wrongly.
7
+ */
8
+ export const ESSENTIAL_ENV: readonly string[] = [
9
+ 'PATH',
10
+ 'HOME',
11
+ 'SHELL',
12
+ 'USER',
13
+ 'LOGNAME',
14
+ 'TMPDIR',
15
+ 'TEMP',
16
+ 'TMP',
17
+ 'LANG',
18
+ 'LC_ALL',
19
+ 'LC_CTYPE',
20
+ 'TERM',
21
+ 'COLORTERM',
22
+ 'FORCE_COLOR',
23
+ 'NO_COLOR',
24
+ 'CI',
25
+ 'NODE_OPTIONS',
26
+ // Where corepack and pnpm keep their own binaries, as HOME is: a machine
27
+ // path, never task output. Without them a corepack shim re-downloads its
28
+ // manager (offline or sandboxed: fails) and pnpm loses its global dir.
29
+ 'COREPACK_HOME',
30
+ 'PNPM_HOME',
31
+ ]
@@ -0,0 +1,107 @@
1
+ import { ESSENTIAL_ENV } from './essential-env.js'
2
+
3
+ const PR_SET_DUMPABLE = 4
4
+
5
+ /**
6
+ * Hide vx's environment from a same-user task. A task gets only declared
7
+ * variables, but vx itself holds the rest. Not a boundary (`exec.sandbox`
8
+ * is): it hides the environment, not files. On every verb (owner,
9
+ * 2026-10-13).
10
+ *
11
+ * Linux: make vx non-dumpable, so its `/proc/<pid>/environ` (and ptrace)
12
+ * close to the task. A child is dumpable again after `exec`, so tasks are
13
+ * unaffected. Loading `bun:ffi` costs ~0.8 ms. `VX_DEBUG_DUMPABLE` keeps vx
14
+ * observable: strace cannot read a non-dumpable tracee's memory, nor a
15
+ * debugger `/proc/<pid>/fd`.
16
+ *
17
+ * macOS: `ps eww` reads the env strings the kernel copied above the
18
+ * stack, and no flag closes that to the same user, so vx zeroes the
19
+ * values there (owner, 2026-10-13: "Do the override then on macOS").
20
+ * Bun copied the env at startup, so `process.env`, and every task built
21
+ * from it, keeps them.
22
+ */
23
+ export async function hideEnvFromTasks(): Promise<boolean> {
24
+ if (process.platform === 'darwin') return wipeStartupEnv()
25
+ if (process.platform !== 'linux' || process.env['VX_DEBUG_DUMPABLE']) return false
26
+ try {
27
+ const { dlopen, FFIType } = await import('bun:ffi')
28
+ const libc = dlopen('libc.so.6', {
29
+ prctl: {
30
+ args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64],
31
+ returns: FFIType.i32,
32
+ },
33
+ })
34
+ const ok = libc.symbols.prctl(PR_SET_DUMPABLE, 0n, 0n, 0n, 0n) === 0
35
+ libc.close()
36
+ return ok
37
+ } catch {
38
+ // No glibc (musl names it otherwise): the env stays as readable as
39
+ // any process's, as it was.
40
+ return false
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Zero the value of every startup variable a task does not see by
46
+ * default (`ESSENTIAL_ENV` stays: every task gets it anyway). The
47
+ * original `envp` array follows `argv`'s NULL on the stack and is never
48
+ * moved, unlike `environ`, which `setenv` may reallocate. libc's
49
+ * `getenv` reads that block, so it answers `''` for a wiped name; vx
50
+ * reads `process.env`.
51
+ */
52
+ async function wipeStartupEnv(): Promise<boolean> {
53
+ try {
54
+ const { dlopen, FFIType, read, toArrayBuffer, CString } = await import('bun:ffi')
55
+ const sys = dlopen('/usr/lib/libSystem.B.dylib', {
56
+ _NSGetArgv: { args: [], returns: FFIType.ptr },
57
+ _NSGetArgc: { args: [], returns: FFIType.ptr },
58
+ })
59
+ const argv = read.ptr(sys.symbols._NSGetArgv()!, 0)
60
+ const argc = read.i32(sys.symbols._NSGetArgc()!, 0)
61
+ sys.close()
62
+ const keep = new Set(ESSENTIAL_ENV)
63
+ for (let i = argc + 1; ; i++) {
64
+ const p = read.ptr(argv, i * 8)
65
+ if (!p) break
66
+ const entry = new CString(p as never).toString()
67
+ const eq = entry.indexOf('=')
68
+ if (eq < 0 || keep.has(entry.slice(0, eq))) continue
69
+ new Uint8Array(toArrayBuffer(p as never, 0, Buffer.byteLength(entry))).fill(0, eq + 1)
70
+ }
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The env for a helper vx keeps running beside its tasks (the group
79
+ * guard, a port bridge): PATH alone. Without an `env`, a spawn hands the
80
+ * child vx's startup env, and that child, dumpable after its `exec`,
81
+ * would show a task what `hideEnvFromTasks` hid.
82
+ */
83
+ export function helperEnv(): Record<string, string> {
84
+ return { PATH: process.env['PATH'] ?? '' }
85
+ }
86
+
87
+ /**
88
+ * The env for a `git` vx runs: what git reads (`GIT_*`, the locale,
89
+ * `XDG_CONFIG_HOME`, the essentials: `HOME`, `PATH`) and nothing else.
90
+ * A git child is readable after its `exec` like any helper, and `vx
91
+ * watch` runs git while its tasks run.
92
+ */
93
+ export function gitEnv(): Record<string, string> {
94
+ const env: Record<string, string> = {}
95
+ for (const [name, value] of Object.entries(process.env)) {
96
+ if (value === undefined) continue
97
+ if (
98
+ name.startsWith('GIT_') ||
99
+ name.startsWith('LC_') ||
100
+ name === 'XDG_CONFIG_HOME' ||
101
+ name === 'LANGUAGE' ||
102
+ ESSENTIAL_ENV.includes(name)
103
+ )
104
+ env[name] = value
105
+ }
106
+ return env
107
+ }