@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.
- package/README.md +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/settings.ts
ADDED
|
@@ -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
|
}
|
package/src/util/config-frame.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -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\.
|
|
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
|
+
}
|