@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
UserError,
|
|
24
24
|
BUN_GLOB_WILDCARDS,
|
|
25
25
|
EXTGLOB,
|
|
26
|
+
braceAlternatives,
|
|
26
27
|
braceRange,
|
|
27
28
|
BRACE_RANGE_LIMIT,
|
|
28
29
|
anyTaskGlob,
|
|
@@ -115,7 +116,7 @@ const oneOf = (values: readonly string[]): string =>
|
|
|
115
116
|
|
|
116
117
|
function validateInputStability(stability: unknown, at: string): void {
|
|
117
118
|
if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
|
|
118
|
-
throw new UserError(`${at} must be an object — \`
|
|
119
|
+
throw new UserError(`${at} must be an object — \`stability: { strategy: 'mutable' }\``)
|
|
119
120
|
}
|
|
120
121
|
assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
|
|
121
122
|
const { strategy, severity } = stability as Record<string, unknown>
|
|
@@ -128,13 +129,13 @@ function validateInputStability(stability: unknown, at: string): void {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
|
-
* `
|
|
132
|
-
* task's `
|
|
132
|
+
* `VX_INPUTS_STABILITY_STRATEGY` and `_SEVERITY`, which override every
|
|
133
|
+
* task's `inputs.stability` for the run: each undefined when unset or
|
|
133
134
|
* empty, refused when it names no value.
|
|
134
135
|
*/
|
|
135
136
|
export function inputStabilityEnv(): InputStability {
|
|
136
|
-
const strategy = settingEnv('
|
|
137
|
-
const severity = settingEnv('
|
|
137
|
+
const strategy = settingEnv('inputs.stability.strategy') as InputStability['strategy']
|
|
138
|
+
const severity = settingEnv('inputs.stability.severity') as InputStability['severity']
|
|
138
139
|
return {
|
|
139
140
|
...(strategy !== undefined ? { strategy } : {}),
|
|
140
141
|
...(severity !== undefined ? { severity } : {}),
|
|
@@ -179,15 +180,17 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
|
|
|
179
180
|
}
|
|
180
181
|
return
|
|
181
182
|
}
|
|
182
|
-
if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
|
|
183
|
+
if (at === 'run.rules.dependencies') validateDependencyRules(value, where, configPath)
|
|
183
184
|
else validateSettingValue(value, at, where)
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
|
|
187
188
|
|
|
188
|
-
function validateDependencyRules(value: unknown, where: string): void {
|
|
189
|
+
function validateDependencyRules(value: unknown, where: string, configPath: string): void {
|
|
189
190
|
const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
|
|
190
191
|
if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
|
|
192
|
+
// The whole path inside one code span, as the frame reads a setting.
|
|
193
|
+
const at = (i: number, rest = '') => `${configPath}: \`run.rules.dependencies[${i}]${rest}\``
|
|
191
194
|
const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
|
|
192
195
|
const matchable = (v: string, field: string) => {
|
|
193
196
|
const why = ruleSelectorProblem(v)
|
|
@@ -198,24 +201,24 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
198
201
|
)
|
|
199
202
|
}
|
|
200
203
|
for (const [i, rule] of (value as unknown[]).entries()) {
|
|
201
|
-
const at = `${where}[${i}]`
|
|
202
204
|
if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
|
|
203
|
-
throw new UserError(`${at} must be { from, allow?, deny? }`)
|
|
204
|
-
assertKnownFields(rule, RULE_FIELDS, at)
|
|
205
|
+
throw new UserError(`${at(i)} must be { from, allow?, deny? }`)
|
|
206
|
+
assertKnownFields(rule, RULE_FIELDS, at(i))
|
|
205
207
|
const { from, allow, deny } = rule as Record<string, unknown>
|
|
206
|
-
if (!selector(from)) throw new UserError(`${at
|
|
207
|
-
matchable(from as string,
|
|
208
|
+
if (!selector(from)) throw new UserError(`${at(i, '.from')} must be a selector like 'tag:app'`)
|
|
209
|
+
matchable(from as string, at(i, '.from'))
|
|
208
210
|
for (const [k, list] of [
|
|
209
211
|
['allow', allow],
|
|
210
212
|
['deny', deny],
|
|
211
213
|
] as const) {
|
|
214
|
+
const field = at(i, `.${k}`)
|
|
212
215
|
if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
|
|
213
|
-
throw new UserError(`${
|
|
214
|
-
for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v,
|
|
216
|
+
throw new UserError(`${field} must be an array of selectors like 'tag:lib'`)
|
|
217
|
+
for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, at(i, `.${k}[${j}]`))
|
|
215
218
|
}
|
|
216
219
|
// A rule that says nothing reads as a constraint and checks nothing.
|
|
217
220
|
if (allow === undefined && deny === undefined)
|
|
218
|
-
throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
|
|
221
|
+
throw new UserError(`${at(i)} names neither \`allow\` nor \`deny\``)
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
|
|
@@ -231,6 +234,8 @@ function ruleSelectorProblem(s: string): string | null {
|
|
|
231
234
|
// The root's own name, which the filter reads as the root's directory.
|
|
232
235
|
if (s === ROOT_NAME) return null
|
|
233
236
|
if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
|
|
237
|
+
// The filter parser refuses one, which the catch below would read as a walk.
|
|
238
|
+
if (hasControl(s)) return 'holds a control character'
|
|
234
239
|
const walk = "starts or ends with '...', which a filter reads as a dependency walk"
|
|
235
240
|
let f: ReturnType<typeof parseFilter>
|
|
236
241
|
try {
|
|
@@ -512,12 +517,14 @@ export function validateProjectConfig(
|
|
|
512
517
|
config: ProjectConfig,
|
|
513
518
|
configPath: string,
|
|
514
519
|
ownConfig: string | null = configPath,
|
|
520
|
+
/** The caller refused non-JSON values already and passes a JSON copy (project-loader.ts). */
|
|
521
|
+
jsonCopy = false,
|
|
515
522
|
): void {
|
|
516
523
|
// Before the schema, on the object as it stands: the key folds its JSON,
|
|
517
524
|
// so a value JSON cannot carry is refused here and in the config worker
|
|
518
525
|
// alike (json-data.ts). A plugin's `project` edit and a lock entry cross
|
|
519
526
|
// this line too.
|
|
520
|
-
const [nonJson] = nonJsonPaths(config)
|
|
527
|
+
const [nonJson] = jsonCopy ? [] : nonJsonPaths(config)
|
|
521
528
|
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
522
529
|
// The top level too: `task:` (singular) loaded as a project with no
|
|
523
530
|
// tasks and every request against it said "no projects declare".
|
|
@@ -590,9 +597,18 @@ export function validateProjectConfig(
|
|
|
590
597
|
const exec = (task as { exec?: unknown }).exec
|
|
591
598
|
const dependsOn = (task as { dependsOn?: unknown }).dependsOn
|
|
592
599
|
const cache = (task as { cache?: unknown }).cache
|
|
600
|
+
if (cache !== undefined && typeof cache !== 'boolean') {
|
|
601
|
+
throw new UserError(`${where}.cache must be true or false`)
|
|
602
|
+
}
|
|
593
603
|
if (exec !== undefined) {
|
|
594
604
|
if (typeof exec !== 'object' || exec === null) {
|
|
595
|
-
|
|
605
|
+
// A command written as the exec itself, as the task-as-a-string
|
|
606
|
+
// refusal above spells its fix.
|
|
607
|
+
throw new UserError(
|
|
608
|
+
typeof exec === 'string'
|
|
609
|
+
? `${where}.exec must be an object — a command is { exec: { command: ${JSON.stringify(exec)} } }`
|
|
610
|
+
: `${where}.exec must be an object with a \`command\` string`,
|
|
611
|
+
)
|
|
596
612
|
}
|
|
597
613
|
assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
|
|
598
614
|
const sandbox = (exec as { sandbox?: unknown }).sandbox
|
|
@@ -623,9 +639,9 @@ export function validateProjectConfig(
|
|
|
623
639
|
// Documented and never held: an uncached 'only' task loaded, and the
|
|
624
640
|
// REAPI executor refused it at run time for want of described inputs
|
|
625
641
|
// (item 1001).
|
|
626
|
-
if (remote === 'only' && cache
|
|
642
|
+
if (remote === 'only' && cache !== true) {
|
|
627
643
|
throw new UserError(
|
|
628
|
-
`${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
|
|
644
|
+
`${where}.exec.remote 'only' needs \`cache: true\`: its inputs are what a worker reproduces ` +
|
|
629
645
|
`and its key is the address of its remote record`,
|
|
630
646
|
)
|
|
631
647
|
}
|
|
@@ -643,11 +659,33 @@ export function validateProjectConfig(
|
|
|
643
659
|
)
|
|
644
660
|
}
|
|
645
661
|
}
|
|
662
|
+
const worker = (exec as { worker?: unknown }).worker
|
|
663
|
+
if (worker !== undefined && (typeof worker !== 'string' || worker.trim().length === 0)) {
|
|
664
|
+
throw new UserError(`${where}.exec.worker must be a worker type's name (or omitted)`)
|
|
665
|
+
}
|
|
646
666
|
const env = (exec as { env?: unknown }).env
|
|
647
667
|
if (env !== undefined) {
|
|
648
668
|
if (typeof env !== 'object' || env === null) {
|
|
649
669
|
throw new UserError(`${where}.exec.env must be an object (or omitted)`)
|
|
650
670
|
}
|
|
671
|
+
// `env: { NODE_ENV: 'production' }` (Turbo's and Nx's shape): name
|
|
672
|
+
// the level the variable belongs at, not only the allowed fields.
|
|
673
|
+
const plain = Array.isArray(env)
|
|
674
|
+
? undefined
|
|
675
|
+
: Object.entries(env).find(
|
|
676
|
+
([k, v]) =>
|
|
677
|
+
!ENV_FIELDS.has(k) &&
|
|
678
|
+
// `passthrough` is a typo of a field, not a variable.
|
|
679
|
+
nearest(k, ENV_FIELDS) === undefined &&
|
|
680
|
+
['string', 'number', 'boolean'].includes(typeof v),
|
|
681
|
+
)
|
|
682
|
+
if (plain !== undefined) {
|
|
683
|
+
throw new UserError(
|
|
684
|
+
`${where}.exec.env has unknown field "${plain[0]}" ` +
|
|
685
|
+
`(allowed: ${[...ENV_FIELDS].sort().join(', ')}) — vx spells it ` +
|
|
686
|
+
`exec.env.define: { ${plain[0]}: ${JSON.stringify(String(plain[1]))} }`,
|
|
687
|
+
)
|
|
688
|
+
}
|
|
651
689
|
// `env: { set: {...} }` loaded and defined nothing — the one level
|
|
652
690
|
// without this check until 2026-09-10 (tests/schema-unknown-keys).
|
|
653
691
|
assertKnownFields(env, ENV_FIELDS, `${where}.exec.env`)
|
|
@@ -712,7 +750,7 @@ export function validateProjectConfig(
|
|
|
712
750
|
throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
|
|
713
751
|
}
|
|
714
752
|
if (interactive === true) {
|
|
715
|
-
if (cache
|
|
753
|
+
if (cache === true) {
|
|
716
754
|
throw new UserError(
|
|
717
755
|
`${where}: \`cache\` is not allowed on an interactive task — what it does ` +
|
|
718
756
|
`depends on what is typed, which no key holds`,
|
|
@@ -765,7 +803,7 @@ export function validateProjectConfig(
|
|
|
765
803
|
)
|
|
766
804
|
}
|
|
767
805
|
}
|
|
768
|
-
if (cache
|
|
806
|
+
if (cache === true) {
|
|
769
807
|
throw new UserError(
|
|
770
808
|
`${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
|
|
771
809
|
`don't terminate, so there's no exit to cache`,
|
|
@@ -801,9 +839,12 @@ export function validateProjectConfig(
|
|
|
801
839
|
}
|
|
802
840
|
if (dependsOn !== undefined) {
|
|
803
841
|
if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
|
|
842
|
+
// One name, not in a list: the fix is that list.
|
|
804
843
|
throw new UserError(
|
|
805
|
-
|
|
806
|
-
|
|
844
|
+
typeof dependsOn === 'string'
|
|
845
|
+
? `${where}.dependsOn must be an array — write dependsOn: [${JSON.stringify(dependsOn)}]`
|
|
846
|
+
: `${where}.dependsOn must be an array of strings ` +
|
|
847
|
+
`(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
|
|
807
848
|
)
|
|
808
849
|
}
|
|
809
850
|
}
|
|
@@ -919,6 +960,10 @@ export function validateProjectConfig(
|
|
|
919
960
|
throw err
|
|
920
961
|
}
|
|
921
962
|
}
|
|
963
|
+
// The selection starts empty and a `!` entry only subtracts, so a
|
|
964
|
+
// list of negations folded no dependency and the key stopped moving
|
|
965
|
+
// with every upstream, as a negation-only inputs.files does.
|
|
966
|
+
assertTaskFiltersNotNegationOnly(taskFilters, `${where}.inputs.tasks`)
|
|
922
967
|
assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
|
|
923
968
|
}
|
|
924
969
|
for (const field of ['runtime', 'workspaceRuntime'] as const) {
|
|
@@ -943,16 +988,8 @@ export function validateProjectConfig(
|
|
|
943
988
|
validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
|
|
944
989
|
}
|
|
945
990
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
throw new UserError(
|
|
949
|
-
`${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
|
|
950
|
-
)
|
|
951
|
-
}
|
|
952
|
-
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
953
|
-
const stability = (cache as { inputStability?: unknown }).inputStability
|
|
954
|
-
if (stability !== undefined)
|
|
955
|
-
validateInputStability(stability, `${where}.cache.inputStability`)
|
|
991
|
+
const outputs = (task as { outputs?: unknown }).outputs
|
|
992
|
+
if (cache === true) {
|
|
956
993
|
// The key is the task's inputs, and a group runs nothing to cache.
|
|
957
994
|
if (exec === undefined) {
|
|
958
995
|
throw new UserError(
|
|
@@ -964,23 +1001,38 @@ export function validateProjectConfig(
|
|
|
964
1001
|
`${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
|
|
965
1002
|
)
|
|
966
1003
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1004
|
+
if (outputs === undefined) {
|
|
1005
|
+
throw new UserError(
|
|
1006
|
+
`${where}: \`cache\` needs \`outputs\` — what the task produces, \`outputs: { files: [...] }\` (\`[]\` for none)`,
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const stability = (inputs as { stability?: unknown } | undefined)?.stability
|
|
1011
|
+
if (stability !== undefined) {
|
|
1012
|
+
validateInputStability(stability, `${where}.inputs.stability`)
|
|
1013
|
+
if (exec === undefined) {
|
|
1014
|
+
throw new UserError(
|
|
1015
|
+
`${where}.inputs.stability needs \`exec\` — a task with no command runs nothing that could write its inputs`,
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (outputs !== undefined) {
|
|
1020
|
+
if (outputs === null || typeof outputs !== 'object') {
|
|
1021
|
+
throw new UserError(`${where}.outputs must be an object — \`outputs: { files: [...] }\``)
|
|
970
1022
|
}
|
|
971
|
-
|
|
1023
|
+
assertKnownFields(outputs, OUTPUT_FIELDS, `${where}.outputs`)
|
|
1024
|
+
if (exec === undefined) {
|
|
972
1025
|
throw new UserError(
|
|
973
|
-
`${where}.
|
|
1026
|
+
`${where}.outputs needs \`exec\` — a task with no command writes nothing`,
|
|
974
1027
|
)
|
|
975
1028
|
}
|
|
976
|
-
assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
|
|
977
1029
|
const clean = (outputs as { clean?: unknown }).clean
|
|
978
1030
|
if (clean !== undefined && typeof clean !== 'boolean') {
|
|
979
|
-
throw new UserError(`${where}.
|
|
1031
|
+
throw new UserError(`${where}.outputs.clean must be true or false`)
|
|
980
1032
|
}
|
|
981
1033
|
const outFiles = (outputs as { files?: unknown }).files
|
|
982
1034
|
if (!Array.isArray(outFiles)) {
|
|
983
|
-
throw new UserError(`${where}.
|
|
1035
|
+
throw new UserError(`${where}.outputs.files must be an array of glob strings`)
|
|
984
1036
|
}
|
|
985
1037
|
// Reject zero-length strings and absolute paths up front.
|
|
986
1038
|
// Both reach `resolveOutputs` as undefined behavior; the error
|
|
@@ -988,26 +1040,26 @@ export function validateProjectConfig(
|
|
|
988
1040
|
// at the user's config. Fail loud at load time.
|
|
989
1041
|
for (const g of outFiles as unknown[]) {
|
|
990
1042
|
if (typeof g !== 'string' || g.length === 0) {
|
|
991
|
-
throw new UserError(`${where}.
|
|
1043
|
+
throw new UserError(`${where}.outputs.files must be an array of non-empty strings`)
|
|
992
1044
|
}
|
|
993
1045
|
if (isAbsoluteGlob(g)) {
|
|
994
1046
|
throw new UserError(
|
|
995
|
-
`${where}.
|
|
1047
|
+
`${where}.outputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
996
1048
|
`outputs must be project-relative globs`,
|
|
997
1049
|
)
|
|
998
1050
|
}
|
|
999
1051
|
if (hasParentSegment(g)) {
|
|
1000
1052
|
throw new UserError(
|
|
1001
|
-
`${where}.
|
|
1053
|
+
`${where}.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
|
|
1002
1054
|
`outputs must stay within the project (vx clears a task's outputs before it runs, ` +
|
|
1003
1055
|
`so a glob that escapes the project would delete files outside it)`,
|
|
1004
1056
|
)
|
|
1005
1057
|
}
|
|
1006
|
-
assertPosixGlob(g, `${where}.
|
|
1007
|
-
assertNoForeignToken(g, `${where}.
|
|
1058
|
+
assertPosixGlob(g, `${where}.outputs.files`, 'project')
|
|
1059
|
+
assertNoForeignToken(g, `${where}.outputs.files`)
|
|
1008
1060
|
if (namesDirItself(g)) {
|
|
1009
1061
|
throw new UserError(
|
|
1010
|
-
`${where}.
|
|
1062
|
+
`${where}.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
|
|
1011
1063
|
`name the directory the task writes, such as "dist/**"`,
|
|
1012
1064
|
)
|
|
1013
1065
|
}
|
|
@@ -1016,31 +1068,31 @@ export function validateProjectConfig(
|
|
|
1016
1068
|
// a "corrupt artifact", dropped and run again.
|
|
1017
1069
|
if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
|
|
1018
1070
|
throw new UserError(
|
|
1019
|
-
`${where}.
|
|
1071
|
+
`${where}.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
|
|
1020
1072
|
`artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
|
|
1021
1073
|
)
|
|
1022
1074
|
}
|
|
1023
1075
|
// A `!` entry takes a path back from the outputs (A-44): it is not
|
|
1024
1076
|
// cleaned, saved or restored, and it stays an input.
|
|
1025
|
-
assertNotDoubleNegated(g, `${where}.
|
|
1077
|
+
assertNotDoubleNegated(g, `${where}.outputs.files`)
|
|
1026
1078
|
// Taken back by a `!`, the file is never cleaned or restored.
|
|
1027
1079
|
const own = g.startsWith('!')
|
|
1028
1080
|
? undefined
|
|
1029
1081
|
: ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
|
|
1030
1082
|
if (own !== undefined) {
|
|
1031
1083
|
throw new UserError(
|
|
1032
|
-
`${where}.
|
|
1084
|
+
`${where}.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
|
|
1033
1085
|
`task's outputs before it runs and restores them on a hit, so the project would lose ` +
|
|
1034
1086
|
`its config. Name the directory the task writes, such as "dist/**", ` +
|
|
1035
1087
|
`or take the file back with "!${own}".`,
|
|
1036
1088
|
)
|
|
1037
1089
|
}
|
|
1038
1090
|
}
|
|
1039
|
-
assertOutputsNotNegationOnly(outFiles as string[], `${where}.
|
|
1040
|
-
assertNoLiteralTakenBack(outFiles as string[], `${where}.
|
|
1091
|
+
assertOutputsNotNegationOnly(outFiles as string[], `${where}.outputs.files`)
|
|
1092
|
+
assertNoLiteralTakenBack(outFiles as string[], `${where}.outputs.files`, OUTPUT_LOST)
|
|
1041
1093
|
const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
|
|
1042
1094
|
if (wsOutputs !== undefined) {
|
|
1043
|
-
validateWorkspaceGlobs(wsOutputs, `${where}.
|
|
1095
|
+
validateWorkspaceGlobs(wsOutputs, `${where}.outputs.workspaceFiles`, false)
|
|
1044
1096
|
}
|
|
1045
1097
|
}
|
|
1046
1098
|
}
|
|
@@ -1055,7 +1107,7 @@ export function validateProjectConfig(
|
|
|
1055
1107
|
// `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
|
|
1056
1108
|
// from both.
|
|
1057
1109
|
const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
|
|
1058
|
-
const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
|
|
1110
|
+
const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'outputs', 'cache'])
|
|
1059
1111
|
const EXEC_FIELDS = new Set([
|
|
1060
1112
|
'command',
|
|
1061
1113
|
'env',
|
|
@@ -1066,10 +1118,10 @@ const EXEC_FIELDS = new Set([
|
|
|
1066
1118
|
'remote',
|
|
1067
1119
|
'sandbox',
|
|
1068
1120
|
'platforms',
|
|
1121
|
+
'worker',
|
|
1069
1122
|
])
|
|
1070
1123
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
1071
1124
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
1072
|
-
const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
|
|
1073
1125
|
const INPUT_FIELDS = new Set([
|
|
1074
1126
|
'files',
|
|
1075
1127
|
'workspaceFiles',
|
|
@@ -1077,8 +1129,9 @@ const INPUT_FIELDS = new Set([
|
|
|
1077
1129
|
'tasks',
|
|
1078
1130
|
'runtime',
|
|
1079
1131
|
'workspaceRuntime',
|
|
1132
|
+
'stability',
|
|
1080
1133
|
])
|
|
1081
|
-
const
|
|
1134
|
+
const OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
|
|
1082
1135
|
|
|
1083
1136
|
/**
|
|
1084
1137
|
* Another runner's spelling of a field vx has under another name, by the
|
|
@@ -1090,8 +1143,10 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1090
1143
|
[
|
|
1091
1144
|
TASK_FIELDS,
|
|
1092
1145
|
{
|
|
1093
|
-
outputs: '`cache.outputs.files`',
|
|
1094
1146
|
env: '`inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
|
|
1147
|
+
// wireit's script keys.
|
|
1148
|
+
output: '`outputs.files`',
|
|
1149
|
+
dependencies: "`dependsOn` (an upstream's key goes into this one)",
|
|
1095
1150
|
passThroughEnv: '`exec.env.passThrough`',
|
|
1096
1151
|
command: '`exec.command`',
|
|
1097
1152
|
cmd: '`exec.command`',
|
|
@@ -1128,18 +1183,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1128
1183
|
shell: 'no field: `command` always runs in a shell',
|
|
1129
1184
|
},
|
|
1130
1185
|
],
|
|
1131
|
-
// wireit's `files` / `output` and include-style spellings written into
|
|
1132
|
-
// a cache block, refused with no word on where they go (D-118).
|
|
1133
|
-
[
|
|
1134
|
-
CACHE_FIELDS,
|
|
1135
|
-
{
|
|
1136
|
-
files: "the task's `inputs.files`",
|
|
1137
|
-
output: '`outputs.files`',
|
|
1138
|
-
env: "the task's `inputs.env`",
|
|
1139
|
-
dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
|
|
1140
|
-
enabled: 'no field: a task with a `cache` block caches, one without runs every time',
|
|
1141
|
-
},
|
|
1142
|
-
],
|
|
1143
1186
|
[
|
|
1144
1187
|
INPUT_FIELDS,
|
|
1145
1188
|
{
|
|
@@ -1151,7 +1194,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1151
1194
|
},
|
|
1152
1195
|
],
|
|
1153
1196
|
[
|
|
1154
|
-
|
|
1197
|
+
OUTPUT_FIELDS,
|
|
1155
1198
|
{
|
|
1156
1199
|
globs: '`files`',
|
|
1157
1200
|
include: '`files`',
|
|
@@ -1176,12 +1219,11 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1176
1219
|
sourceRoot: "a task's `inputs.files`",
|
|
1177
1220
|
projectType: 'no field: vx runs every project alike',
|
|
1178
1221
|
...Object.fromEntries(
|
|
1179
|
-
['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
|
|
1222
|
+
['dependsOn', 'inputs', 'outputs', 'cache', 'exec', 'description'].map((k) => [
|
|
1180
1223
|
k,
|
|
1181
1224
|
`\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
|
|
1182
1225
|
]),
|
|
1183
1226
|
),
|
|
1184
|
-
outputs: '`tasks.<name>.cache.outputs.files`',
|
|
1185
1227
|
env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
|
|
1186
1228
|
},
|
|
1187
1229
|
],
|
|
@@ -1252,7 +1294,7 @@ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefin
|
|
|
1252
1294
|
for (const [field, own] of [
|
|
1253
1295
|
['exec', EXEC_FIELDS],
|
|
1254
1296
|
['inputs', INPUT_FIELDS],
|
|
1255
|
-
['
|
|
1297
|
+
['outputs', OUTPUT_FIELDS],
|
|
1256
1298
|
] as const) {
|
|
1257
1299
|
if (own.has(key)) return `\`${field}.${key}\``
|
|
1258
1300
|
}
|
|
@@ -1273,7 +1315,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
|
|
|
1273
1315
|
if (!allowed.has(key)) {
|
|
1274
1316
|
// The nearest accepted spelling first: the list says what the level
|
|
1275
1317
|
// takes, the hint says which one was meant.
|
|
1276
|
-
|
|
1318
|
+
// Own keys only: `constructor` read Object's and printed its source.
|
|
1319
|
+
const spellings = FOREIGN_FIELDS.get(allowed)
|
|
1320
|
+
const foreign =
|
|
1321
|
+
(spellings !== undefined && Object.hasOwn(spellings, key) ? spellings[key] : undefined) ??
|
|
1322
|
+
nestedHome(allowed, key)
|
|
1277
1323
|
const near =
|
|
1278
1324
|
nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
|
|
1279
1325
|
throw new UserError(
|
|
@@ -1383,14 +1429,25 @@ function assertNoForeignToken(glob: string, where: string): void {
|
|
|
1383
1429
|
* and names both.
|
|
1384
1430
|
*/
|
|
1385
1431
|
function assertNoGluedGlobstar(glob: string, where: string): void {
|
|
1432
|
+
if (gluesGlobstar(glob)) throw gluedGlobstar(glob, `"${glob}"`, where)
|
|
1433
|
+
// Split on `{` `}` `,` alone, `src/{a,**}.ts` passed and expanded to
|
|
1434
|
+
// `src/**.ts`, one level.
|
|
1435
|
+
const alt = braceAlternatives(glob).find(gluesGlobstar)
|
|
1436
|
+
if (alt !== undefined) throw gluedGlobstar(alt, `"${glob}" expands to "${alt}": that`, where)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function gluesGlobstar(glob: string): boolean {
|
|
1386
1440
|
const body = glob.startsWith('!') ? glob.slice(1) : glob
|
|
1387
|
-
|
|
1441
|
+
return body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function gluedGlobstar(glob: string, subject: string, where: string): UserError {
|
|
1388
1445
|
const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
|
|
1389
1446
|
const one = glob.replace(/\*{2,}/g, (run, at: number) =>
|
|
1390
1447
|
/[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
|
|
1391
1448
|
)
|
|
1392
|
-
|
|
1393
|
-
`${where}:
|
|
1449
|
+
return new UserError(
|
|
1450
|
+
`${where}: ${subject} glues ** to other text in one path segment, which vx's glob engine ` +
|
|
1394
1451
|
`reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
|
|
1395
1452
|
`for one level.`,
|
|
1396
1453
|
)
|
|
@@ -1538,7 +1595,7 @@ function assertInputGlob(glob: string, where: string): void {
|
|
|
1538
1595
|
if (glob.replace(/^!/, '').split('/').includes('.git')) {
|
|
1539
1596
|
throw new UserError(
|
|
1540
1597
|
`${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
|
|
1541
|
-
`inputs are the
|
|
1598
|
+
`inputs are the workspace's files`,
|
|
1542
1599
|
)
|
|
1543
1600
|
}
|
|
1544
1601
|
}
|
|
@@ -1662,8 +1719,8 @@ function isEnvName(name: unknown): name is string {
|
|
|
1662
1719
|
* A task name `dependsOn`, `inputs.tasks` and the CLI can name. Each
|
|
1663
1720
|
* refused shape loaded and could not be referenced: `x#y` read as project
|
|
1664
1721
|
* `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
|
|
1665
|
-
* under `--all` as `a#` and `vx run a#` refused it,
|
|
1666
|
-
* trimmed away by the reader (item 1000).
|
|
1722
|
+
* under `--all` as `a#` and `vx run a#` refused it, `-x` as a flag, and a
|
|
1723
|
+
* padded name was trimmed away by the reader (item 1000).
|
|
1667
1724
|
*/
|
|
1668
1725
|
function assertTaskName(name: string, configPath: string): void {
|
|
1669
1726
|
const why = taskNameProblem(name)
|
|
@@ -1686,9 +1743,11 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1686
1743
|
? "holds '*', which makes it a pattern"
|
|
1687
1744
|
: name.startsWith('^') || name.startsWith('!')
|
|
1688
1745
|
? `starts with '${name[0]}', which names dependencies' tasks or negates`
|
|
1689
|
-
:
|
|
1690
|
-
? '
|
|
1691
|
-
:
|
|
1746
|
+
: name.startsWith('-')
|
|
1747
|
+
? "starts with '-', which the CLI reads as a flag"
|
|
1748
|
+
: hasControl(name)
|
|
1749
|
+
? 'holds a control character'
|
|
1750
|
+
: null
|
|
1692
1751
|
}
|
|
1693
1752
|
|
|
1694
1753
|
/**
|
|
@@ -1699,6 +1758,9 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1699
1758
|
function tagProblem(tag: string): string | null {
|
|
1700
1759
|
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1701
1760
|
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1761
|
+
// Refused by the caller in its own words; the filter parser refuses one
|
|
1762
|
+
// too, which the catch below would read as a walk.
|
|
1763
|
+
if (hasControl(tag)) return null
|
|
1702
1764
|
const walk = "ends in '...', which a filter reads as a dependency walk"
|
|
1703
1765
|
let f: ReturnType<typeof parseFilter>
|
|
1704
1766
|
try {
|
|
@@ -1737,6 +1799,18 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
|
|
|
1737
1799
|
)
|
|
1738
1800
|
}
|
|
1739
1801
|
|
|
1802
|
+
function assertTaskFiltersNotNegationOnly(filters: readonly string[], where: string): void {
|
|
1803
|
+
if (filters.length === 0) return
|
|
1804
|
+
if (filters.some((f) => !f.startsWith('!'))) return
|
|
1805
|
+
throw new UserError(
|
|
1806
|
+
`${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(filters)}) — ` +
|
|
1807
|
+
`negations only subtract from the dependencies a positive entry already selected, so this ` +
|
|
1808
|
+
`task would fold no upstream hash and its cache key would stop tracking its dependencies. ` +
|
|
1809
|
+
`Add the positive entry you meant, e.g. ['*', '^*', ${JSON.stringify(filters[0])}], ` +
|
|
1810
|
+
`or use [] to decouple on purpose.`,
|
|
1811
|
+
)
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1740
1814
|
/**
|
|
1741
1815
|
* A `!` entry subtracts wherever it sits in the list, so a file named
|
|
1742
1816
|
* outright after it (`['src/**', '!src/gen/**', 'src/gen/keep.ts']`) never
|
|
@@ -1828,6 +1902,7 @@ const SANDBOX_FIELDS = new Set([
|
|
|
1828
1902
|
'ignore',
|
|
1829
1903
|
'weakerWhenNested',
|
|
1830
1904
|
'weakerNetworkIsolation',
|
|
1905
|
+
'hermetic',
|
|
1831
1906
|
])
|
|
1832
1907
|
const GRANT_PATH_FIELDS = ['read', 'write'] as const
|
|
1833
1908
|
const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
|
|
@@ -1855,6 +1930,19 @@ function assertStringArray(v: unknown, where: string): void {
|
|
|
1855
1930
|
}
|
|
1856
1931
|
}
|
|
1857
1932
|
|
|
1933
|
+
/**
|
|
1934
|
+
* Only `~` and `~/…` mean the home: `~root/x/` was read as `$HOME/root/x`,
|
|
1935
|
+
* and vx created that directory on the host.
|
|
1936
|
+
*/
|
|
1937
|
+
function assertOwnHome(v: readonly string[], where: string): void {
|
|
1938
|
+
const other = v.find((p) => p.startsWith('~') && p !== '~' && !p.startsWith('~/'))
|
|
1939
|
+
if (other === undefined) return
|
|
1940
|
+
throw new UserError(
|
|
1941
|
+
`${where}: ${JSON.stringify(other)} names a user's home, which vx does not expand — ` +
|
|
1942
|
+
`write '~/…' for your own home, or an absolute path`,
|
|
1943
|
+
)
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1858
1946
|
/**
|
|
1859
1947
|
* Validate a `sandbox: {...}` block — one capability shape, whatever the
|
|
1860
1948
|
* platform ends up doing with it. Unknown keys are refused rather than
|
|
@@ -1871,7 +1959,7 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1871
1959
|
assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
|
|
1872
1960
|
const obj = sandbox as Record<string, unknown>
|
|
1873
1961
|
|
|
1874
|
-
for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
|
|
1962
|
+
for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation', 'hermetic']) {
|
|
1875
1963
|
if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
|
|
1876
1964
|
throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
|
|
1877
1965
|
}
|
|
@@ -1887,7 +1975,22 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1887
1975
|
for (const f of GRANT_PATH_FIELDS) {
|
|
1888
1976
|
// Patterns are allowed here: macOS matches them natively, Linux
|
|
1889
1977
|
// expands them against the filesystem at resolve time.
|
|
1890
|
-
if (g[f]
|
|
1978
|
+
if (g[f] === undefined) continue
|
|
1979
|
+
assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
|
|
1980
|
+
assertOwnHome(g[f] as string[], `${where}.sandbox.allow.${f}`)
|
|
1981
|
+
}
|
|
1982
|
+
// SRT's Linux observer reports a write's path with each control
|
|
1983
|
+
// character turned into a space, so a write a grant holding one covers
|
|
1984
|
+
// read as a violation of the path spelled with a space, and a real
|
|
1985
|
+
// write there reads the same: no match passes one and not the other.
|
|
1986
|
+
// Reads are traced by strace, which escapes them.
|
|
1987
|
+
const ctl = ((g['write'] as string[] | undefined) ?? []).find(hasControl)
|
|
1988
|
+
if (ctl !== undefined) {
|
|
1989
|
+
throw new UserError(
|
|
1990
|
+
`${where}.sandbox.allow.write: ${JSON.stringify(ctl)} holds a control character, ` +
|
|
1991
|
+
`which the Linux sandbox's report of a write does not keep (it spells a tab as a ` +
|
|
1992
|
+
`space), so a write under it cannot be told from a refused one — rename it`,
|
|
1993
|
+
)
|
|
1891
1994
|
}
|
|
1892
1995
|
for (const f of GRANT_NAME_FIELDS) {
|
|
1893
1996
|
if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
|
|
@@ -1944,7 +2047,10 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1944
2047
|
// Patterns, not prefixes — `ignore` is vx's own filter over lines it
|
|
1945
2048
|
// already holds, so a glob costs nothing and works on every platform.
|
|
1946
2049
|
for (const f of IGNORE_FIELDS) {
|
|
1947
|
-
if (g[f]
|
|
2050
|
+
if (g[f] === undefined) continue
|
|
2051
|
+
assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
|
|
2052
|
+
if (f === 'read' || f === 'write')
|
|
2053
|
+
assertOwnHome(g[f] as string[], `${where}.sandbox.ignore.${f}`)
|
|
1948
2054
|
}
|
|
1949
2055
|
}
|
|
1950
2056
|
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// changed lockfile's base digests are there, so the base side is never
|
|
12
12
|
// parsed. `vx lock --check` holds it to a fresh derivation.
|
|
13
13
|
|
|
14
|
-
import { rename, rm } from 'node:fs/promises'
|
|
15
14
|
import path from 'node:path'
|
|
15
|
+
import type { PendingWrite } from '../util/index.js'
|
|
16
16
|
import type { ProjectMeta } from './workspace.js'
|
|
17
17
|
import { lockKey } from './lockfile.js'
|
|
18
18
|
import { type LoadReads, readOnce } from './load-reads.js'
|
|
@@ -90,19 +90,12 @@ function dependenciesLockText(lock: DependenciesLock): string {
|
|
|
90
90
|
return `${JSON.stringify(lock)}\n`
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
export
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
await rename(tmp, file)
|
|
100
|
-
} catch (err) {
|
|
101
|
-
await rm(tmp, { force: true }).catch(() => {})
|
|
102
|
-
const e = err as NodeJS.ErrnoException
|
|
103
|
-
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
104
|
-
if (e.path === tmp) e.path = file
|
|
105
|
-
throw err
|
|
93
|
+
/** The dependency lock's file and bytes, for `writeAllOrNone`. */
|
|
94
|
+
export function dependenciesLockWrite(root: string, lock: DependenciesLock): PendingWrite {
|
|
95
|
+
return {
|
|
96
|
+
file: path.join(root, DEPENDENCIES_LOCK_NAME),
|
|
97
|
+
text: dependenciesLockText(lock),
|
|
98
|
+
shown: DEPENDENCIES_LOCK_NAME,
|
|
106
99
|
}
|
|
107
100
|
}
|
|
108
101
|
|