@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/cli/lock.ts
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
// `vx lock` — freeze every resolved vx.config.* into vx
|
|
1
|
+
// `vx lock` — freeze every resolved vx.config.* into .vx/projects.lock.json, and
|
|
2
|
+
// each one, pretty, into a `vx.lock.json` beside it for review.
|
|
2
3
|
// `vx lock --check` — audit the lock against a fresh evaluation in the
|
|
3
4
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
5
|
|
|
6
|
+
import { open } from 'node:fs/promises'
|
|
7
|
+
import path from 'node:path'
|
|
5
8
|
import type { ProjectConfig } from '../config.js'
|
|
6
9
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
|
-
import {
|
|
10
|
+
import { buildTaskGraph, expandRequested } from '../graph/index.js'
|
|
8
11
|
import {
|
|
12
|
+
applyGraphStage,
|
|
13
|
+
discoverProjects,
|
|
14
|
+
hasHook,
|
|
15
|
+
isDefaultBuild,
|
|
16
|
+
loadProjects,
|
|
17
|
+
} from '../orchestrator/index.js'
|
|
18
|
+
import { resolveSetting, runRules } from '../settings.js'
|
|
19
|
+
import {
|
|
20
|
+
refusalNotice,
|
|
21
|
+
DOT_VX_GITATTRIBUTES,
|
|
22
|
+
isUserError,
|
|
23
|
+
relPosix,
|
|
24
|
+
type RefusalParts,
|
|
25
|
+
secretMask,
|
|
26
|
+
ui,
|
|
27
|
+
sayNotice,
|
|
28
|
+
} from '../util/index.js'
|
|
29
|
+
import {
|
|
30
|
+
findProjectConfigs,
|
|
9
31
|
findWorkspaceRoot,
|
|
10
|
-
|
|
11
|
-
loadProjectConfig,
|
|
12
|
-
loadWorkspace,
|
|
32
|
+
loadProjectConfigs,
|
|
13
33
|
LOCKFILE_NAME,
|
|
14
34
|
LOCKFILE_VERSION,
|
|
35
|
+
lockKey,
|
|
15
36
|
readLockfile,
|
|
37
|
+
buildPackageGraph,
|
|
38
|
+
checkDependencyRules,
|
|
16
39
|
writeLockfile,
|
|
17
40
|
type Lockfile,
|
|
18
|
-
type LockfileEntry,
|
|
19
41
|
type ProjectMeta,
|
|
20
42
|
} from '../workspace/index.js'
|
|
21
|
-
import {
|
|
43
|
+
import { taskEdgesFrom } from './select.js'
|
|
44
|
+
import { loadCliWorkspace, warnToStderr } from './workspace-config.js'
|
|
22
45
|
|
|
23
46
|
interface LockArgs {
|
|
24
47
|
check: boolean
|
|
@@ -57,69 +80,264 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
57
80
|
`--format json answers --check only${seeHelp('lock')}`,
|
|
58
81
|
'VX_E_USAGE',
|
|
59
82
|
)
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
async function
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
83
|
+
const root = await findWorkspaceRoot(process.cwd())
|
|
84
|
+
if (parsed.check) return await checkLock(root, parsed.format === 'json')
|
|
85
|
+
return await lockWorkspace(root)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Projects with tasks and no vx.config: a plugin's tasks, which load live
|
|
90
|
+
* in every run, so both verbs name them — `0 project configs`
|
|
91
|
+
* on a turbo() repo read like an audit of something (2026-09-16). One with
|
|
92
|
+
* no task is left out: solidjs/solid's solid-ssr, which has no script Turbo
|
|
93
|
+
* runs, read as a project whose tasks come live from the plugin `project` stage.
|
|
94
|
+
*/
|
|
95
|
+
function bareProjects(all: readonly ProjectMeta[], tasked: ReadonlySet<string>): string[] {
|
|
96
|
+
return all.filter((m) => m.configPath === null && tasked.has(m.name)).map((m) => m.name)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `vx lock`: find and evaluate every config, check the graph rules over
|
|
101
|
+
* every task, and write the lock: the only writer of it.
|
|
102
|
+
*/
|
|
103
|
+
export async function lockWorkspace(
|
|
104
|
+
root: string,
|
|
105
|
+
warn: (message: string) => void = warnToStderr,
|
|
106
|
+
say: (line: string) => void = (line) => ui.print('out', ui.status('ok', 'vx lock', line)),
|
|
107
|
+
): Promise<number> {
|
|
108
|
+
const check = await audit(root, warn)
|
|
109
|
+
if (check.broken !== null) {
|
|
110
|
+
notWritten(check.broken, check.brokenParts)
|
|
111
|
+
return 1
|
|
112
|
+
}
|
|
113
|
+
return await writeLock(root, check, say)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The lock as `vx lock` would write it, and the graph rules (`run.rules`)
|
|
118
|
+
* checked over every project and task the workspace declares: a run trusts
|
|
119
|
+
* the lock and does not check them (owner, 2026-10-13). Every config is
|
|
120
|
+
* found (`findProjectConfigs`) and evaluated once; the projects, names and
|
|
121
|
+
* graph are the ones a run builds from that lock, the `discover` and
|
|
122
|
+
* `graph` stages included.
|
|
123
|
+
*/
|
|
124
|
+
async function audit(root: string, warn: (message: string) => void): Promise<GraphCheck> {
|
|
125
|
+
const tasked = new Set<string>()
|
|
126
|
+
const resolved = new Map<string, ProjectConfig>()
|
|
127
|
+
const out: GraphCheck = {
|
|
128
|
+
broken: null,
|
|
129
|
+
fresh: { version: LOCKFILE_VERSION, projects: {} },
|
|
130
|
+
all: [],
|
|
131
|
+
tasked,
|
|
132
|
+
resolved,
|
|
133
|
+
reviewFiles: true,
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
|
|
137
|
+
out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
|
|
138
|
+
const found = await findProjectConfigs(root)
|
|
139
|
+
// The one load that evaluates: every reader reads the lock this writes.
|
|
140
|
+
const configs = await loadProjectConfigs(found.map((f) => f.configPath))
|
|
141
|
+
// In the lock's form: a field left `undefined` is a key the stored JSON
|
|
142
|
+
// never has. Entries, not assignment: a directory named `__proto__` is
|
|
143
|
+
// a key of its own (D-152).
|
|
144
|
+
out.fresh.projects = Object.fromEntries(
|
|
145
|
+
found.map((f, i) => [
|
|
146
|
+
lockKey(root, f.dir),
|
|
147
|
+
{
|
|
148
|
+
file: path.basename(f.configPath),
|
|
149
|
+
config: JSON.parse(JSON.stringify(configs[i])) as ProjectConfig,
|
|
150
|
+
},
|
|
151
|
+
]),
|
|
152
|
+
)
|
|
153
|
+
// The stages edit what they are handed; the lock keeps what the configs say.
|
|
154
|
+
const view = structuredClone(out.fresh)
|
|
155
|
+
const lock = async (): Promise<Lockfile> => view
|
|
156
|
+
out.all = await discoverProjects(root, lock, plugins, cacheDir, warn)
|
|
157
|
+
const { projects } = await loadProjects({
|
|
158
|
+
workspaceRoot: root,
|
|
159
|
+
cacheDir,
|
|
160
|
+
plugins,
|
|
161
|
+
projectMetas: out.all,
|
|
162
|
+
seeds: 'all',
|
|
163
|
+
closure: false,
|
|
164
|
+
configs: lock,
|
|
165
|
+
warn,
|
|
166
|
+
lock: true,
|
|
167
|
+
})
|
|
168
|
+
for (const entry of projects.values()) resolved.set(entry.name, entry.config)
|
|
169
|
+
const names = new Set<string>()
|
|
170
|
+
for (const entry of projects.values()) {
|
|
171
|
+
const tasks = Object.entries(entry.config.tasks ?? {})
|
|
172
|
+
// Every project has a `build`; core's default one is nothing to freeze.
|
|
173
|
+
if (tasks.some(([, t]) => !isDefaultBuild(t))) tasked.add(entry.name)
|
|
174
|
+
for (const [t] of tasks) names.add(t)
|
|
175
|
+
}
|
|
176
|
+
const dependencyRules = workspaceConfig?.run?.rules?.dependencies ?? []
|
|
177
|
+
if (dependencyRules.length > 0)
|
|
178
|
+
checkDependencyRules(
|
|
179
|
+
dependencyRules,
|
|
180
|
+
[...projects.keys()],
|
|
181
|
+
new Map([...projects.values()].map((p) => [p.name, p.config.tags ?? []])),
|
|
182
|
+
buildPackageGraph(out.all, taskEdgesFrom(projects)),
|
|
183
|
+
)
|
|
184
|
+
const rules = runRules(workspaceConfig)
|
|
185
|
+
const nodes = buildTaskGraph({
|
|
186
|
+
projects,
|
|
187
|
+
packageGraph: buildPackageGraph(out.all),
|
|
188
|
+
requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
|
|
189
|
+
workspaceRoot: root,
|
|
190
|
+
rules,
|
|
191
|
+
})
|
|
192
|
+
if (hasHook(plugins, 'graph'))
|
|
193
|
+
await applyGraphStage(plugins, nodes, {
|
|
194
|
+
workspaceRoot: root,
|
|
195
|
+
cacheDir,
|
|
196
|
+
projectMetas: out.all,
|
|
197
|
+
warn,
|
|
198
|
+
rules,
|
|
199
|
+
})
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (isUserError(err))
|
|
202
|
+
return { ...out, broken: err.message, ...(err.parts ? { brokenParts: err.parts } : {}) }
|
|
203
|
+
throw err
|
|
204
|
+
}
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface GraphCheck {
|
|
209
|
+
/** A graph rule's refusal, or a config that does not load; null when sound. */
|
|
210
|
+
broken: string | null
|
|
211
|
+
brokenParts?: RefusalParts
|
|
212
|
+
/** The lock the configs evaluate to here. */
|
|
213
|
+
fresh: Lockfile
|
|
214
|
+
/** Every project, as a run discovers them from `fresh`. */
|
|
215
|
+
all: ProjectMeta[]
|
|
216
|
+
/** The projects that have a task. */
|
|
217
|
+
tasked: Set<string>
|
|
218
|
+
/** Each project's config as a run resolves it, plugins applied. */
|
|
219
|
+
resolved: Map<string, ProjectConfig>
|
|
220
|
+
/** `lock.reviewFiles`. */
|
|
221
|
+
reviewFiles: boolean
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
|
|
225
|
+
return all.filter((m): m is ConfiguredMeta => m.configPath !== null)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `vx init`'s last step: the lock, as `vx lock` writes it, its line
|
|
230
|
+
* returned for the report's `lock` row. A refusal (a graph rule, a config
|
|
231
|
+
* that does not load) is printed and leaves the written configs in place.
|
|
232
|
+
*/
|
|
233
|
+
export async function lockAfterMigration(root: string): Promise<{ code: number; said: string }> {
|
|
234
|
+
let said = ''
|
|
235
|
+
try {
|
|
236
|
+
const code = await lockWorkspace(root, warnToStderr, (line) => (said = line))
|
|
237
|
+
return { code, said }
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (!isUserError(err)) throw err
|
|
240
|
+
notWritten(err.message, err.parts)
|
|
241
|
+
return { code: 1, said }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The lock refused: the refusal's own parts, or its config line and frame, or its message. */
|
|
246
|
+
function notWritten(message: string, parts: RefusalParts | undefined): void {
|
|
247
|
+
ui.print('err', refusalNotice('vx lock', message, parts, { headline: 'lock not written' }))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Names shown per note: a turbo() repo can have hundreds of bare projects. */
|
|
251
|
+
const BARE_SHOWN = 5
|
|
252
|
+
|
|
253
|
+
function bareNames(bare: readonly string[]): string {
|
|
254
|
+
const more = bare.length - BARE_SHOWN
|
|
255
|
+
return more > 0 ? `${bare.slice(0, BARE_SHOWN).join(', ')} and ${more} more` : bare.join(', ')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function bareNote(bare: readonly string[]): string {
|
|
259
|
+
return bare.length === 0 ? '' : ` (no vx.config, so not locked: ${bareNames(bare)})`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The name `vx lock` writes each project's review copy under. */
|
|
263
|
+
const REVIEW_FILE = 'vx.lock.json'
|
|
264
|
+
|
|
265
|
+
interface Review {
|
|
266
|
+
name: string
|
|
267
|
+
file: string
|
|
268
|
+
config: ProjectConfig
|
|
269
|
+
text: string
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Each configured project's review copy: its resolved config, pretty. */
|
|
273
|
+
function sameJson(text: string, config: ProjectConfig): boolean {
|
|
274
|
+
try {
|
|
275
|
+
return Bun.deepEquals(JSON.parse(text), JSON.parse(JSON.stringify(config)), true)
|
|
276
|
+
} catch {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function reviewsOf(metas: ConfiguredMeta[], resolved: Map<string, ProjectConfig>): Review[] {
|
|
282
|
+
return metas.flatMap((m) => {
|
|
283
|
+
const config = resolved.get(m.name)
|
|
284
|
+
if (config === undefined) return []
|
|
285
|
+
return [
|
|
286
|
+
{
|
|
287
|
+
name: m.name,
|
|
288
|
+
file: path.join(path.dirname(m.configPath), REVIEW_FILE),
|
|
289
|
+
config,
|
|
290
|
+
text: `${JSON.stringify(config, null, 2)}\n`,
|
|
291
|
+
},
|
|
292
|
+
]
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Writes `text` to `file` unless it exists: one a user edited stays theirs. */
|
|
297
|
+
async function writeIfMissing(file: string, text: string): Promise<void> {
|
|
298
|
+
try {
|
|
299
|
+
const fh = await open(file, 'wx')
|
|
300
|
+
await fh.writeFile(text)
|
|
301
|
+
await fh.close()
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function writeLock(
|
|
308
|
+
root: string,
|
|
309
|
+
check: GraphCheck,
|
|
310
|
+
say: (line: string) => void,
|
|
311
|
+
): Promise<number> {
|
|
312
|
+
const metas = configured(check.all)
|
|
313
|
+
const bare = bareProjects(check.all, check.tasked)
|
|
314
|
+
// By project name, as the refusal names them.
|
|
315
|
+
const projects: Record<string, ProjectConfig> = Object.fromEntries(
|
|
316
|
+
metas.map((m) => [m.name, check.fresh.projects[lockKey(root, m.dir)]!.config]),
|
|
107
317
|
)
|
|
318
|
+
const reviews = check.reviewFiles ? reviewsOf(metas, check.resolved) : []
|
|
319
|
+
// The review copies are committed too; a plugin's `project` stage can add
|
|
320
|
+
// a value the bare config lacks. A place both hold is named once.
|
|
108
321
|
const leaks = secretsIn(projects)
|
|
322
|
+
const named = new Set(leaks.map((l) => l.at))
|
|
323
|
+
for (const l of secretsIn(Object.fromEntries(reviews.map((r) => [r.name, r.config]))))
|
|
324
|
+
if (!named.has(l.at)) leaks.push(l)
|
|
109
325
|
if (leaks.length > 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
326
|
+
notWritten('', {
|
|
327
|
+
headline: 'these configs evaluated to a secret value',
|
|
328
|
+
what: leaks.map((l) => l.at),
|
|
329
|
+
why: 'the lock is committed, so the secret would be too',
|
|
330
|
+
fix: `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config`,
|
|
331
|
+
})
|
|
115
332
|
return 1
|
|
116
333
|
}
|
|
117
|
-
|
|
118
|
-
await
|
|
334
|
+
await writeLockfile(root, check.fresh)
|
|
335
|
+
await Promise.all([
|
|
336
|
+
...reviews.map((r) => Bun.write(r.file, r.text)),
|
|
337
|
+
writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
|
|
338
|
+
])
|
|
119
339
|
const n = metas.length
|
|
120
|
-
|
|
121
|
-
`vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
|
|
122
|
-
)
|
|
340
|
+
say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
|
|
123
341
|
return 0
|
|
124
342
|
}
|
|
125
343
|
|
|
@@ -128,12 +346,12 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
128
346
|
* environment, by the rule output masking uses (a secret-named variable,
|
|
129
347
|
* or one a task lists in `exec.env.secret`). The lock is committed, so
|
|
130
348
|
* such a value would be published, and masking it would freeze a `***`
|
|
131
|
-
* that
|
|
349
|
+
* that every run runs (L-42).
|
|
132
350
|
*/
|
|
133
|
-
function secretsIn(projects: Record<string,
|
|
351
|
+
function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string; name: string }> {
|
|
134
352
|
const named = new Set<string>()
|
|
135
|
-
for (const
|
|
136
|
-
for (const task of Object.values(
|
|
353
|
+
for (const config of Object.values(projects)) {
|
|
354
|
+
for (const task of Object.values(config.tasks ?? {}))
|
|
137
355
|
for (const n of task?.exec?.env?.secret ?? []) named.add(n)
|
|
138
356
|
}
|
|
139
357
|
const secrets: [value: string, name: string][] = []
|
|
@@ -153,30 +371,27 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
|
|
|
153
371
|
for (const [k, x] of Object.entries(v)) walk(project, at === '' ? k : `${at}.${k}`, x)
|
|
154
372
|
}
|
|
155
373
|
}
|
|
156
|
-
for (const [project,
|
|
374
|
+
for (const [project, config] of Object.entries(projects)) walk(project, '', config)
|
|
157
375
|
return found
|
|
158
376
|
}
|
|
159
377
|
|
|
160
378
|
/**
|
|
161
|
-
* Audit the lock
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* catches eval-time env-var drift that file hashes cannot see —
|
|
166
|
-
* file bytes unchanged, resolved value changed.
|
|
379
|
+
* Audit the lock: find and re-evaluate every config in the CURRENT
|
|
380
|
+
* environment and deep-compare it with the locked object. That sees an
|
|
381
|
+
* edit to the config, to a file it imports, and to the environment it
|
|
382
|
+
* reads alike, and a config added or removed since.
|
|
167
383
|
*/
|
|
168
|
-
async function checkLock(
|
|
169
|
-
root
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
): Promise<number> {
|
|
384
|
+
async function checkLock(root: string, json: boolean): Promise<number> {
|
|
385
|
+
const check = await audit(root, warnToStderr)
|
|
386
|
+
const { broken } = check
|
|
387
|
+
const metas = configured(check.all)
|
|
388
|
+
const bare = bareProjects(check.all, check.tasked)
|
|
174
389
|
const answer = (problems: string[]): number => {
|
|
175
390
|
if (json) {
|
|
176
391
|
const out = {
|
|
177
392
|
upToDate: problems.length === 0,
|
|
178
393
|
audited: metas.length,
|
|
179
|
-
notAudited: bare,
|
|
394
|
+
notAudited: bare.length,
|
|
180
395
|
problems,
|
|
181
396
|
}
|
|
182
397
|
process.stdout.write(`${JSON.stringify(out)}\n`)
|
|
@@ -186,55 +401,74 @@ async function checkLock(
|
|
|
186
401
|
const lock = await readLockfile(root)
|
|
187
402
|
if (!lock) {
|
|
188
403
|
const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
|
|
189
|
-
if (!json)
|
|
404
|
+
if (!json) sayNotice('error', 'vx lock --check', problem)
|
|
190
405
|
return answer([problem])
|
|
191
406
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
)
|
|
407
|
+
const failures: string[] = []
|
|
408
|
+
const drifted = new Set<string>()
|
|
409
|
+
for (const [dir, fresh] of Object.entries(check.fresh.projects)) {
|
|
410
|
+
const rel = dir === '.' ? fresh.file : `${dir}/${fresh.file}`
|
|
411
|
+
const locked = Object.hasOwn(lock.projects, dir) ? lock.projects[dir] : undefined
|
|
412
|
+
const problem =
|
|
413
|
+
locked === undefined
|
|
414
|
+
? `${rel} is not in the lock — run 'vx lock'`
|
|
415
|
+
: locked.file !== fresh.file
|
|
416
|
+
? `${rel} is not the ${locked.file} the lock holds for ${dir} — run 'vx lock'`
|
|
417
|
+
: !Bun.deepEquals(fresh.config, locked.config, true)
|
|
418
|
+
? `${rel} evaluates differently from the lock here — its config, a file ` +
|
|
419
|
+
`it imports, or the environment it reads changed; run 'vx lock'`
|
|
420
|
+
: null
|
|
421
|
+
if (problem === null) continue
|
|
422
|
+
failures.push(problem)
|
|
423
|
+
drifted.add(dir)
|
|
424
|
+
}
|
|
425
|
+
if (broken !== null) failures.push(broken)
|
|
426
|
+
if (check.reviewFiles && broken === null) {
|
|
427
|
+
// A project the lock already fails is named once.
|
|
428
|
+
const stale = await Promise.all(
|
|
429
|
+
reviewsOf(
|
|
430
|
+
metas.filter((m) => !drifted.has(lockKey(root, m.dir))),
|
|
431
|
+
check.resolved,
|
|
432
|
+
).map(async (r) => {
|
|
433
|
+
const text = await Bun.file(r.file)
|
|
434
|
+
.text()
|
|
435
|
+
.catch(() => null)
|
|
436
|
+
// Compared as JSON: a formatter may lay the copy out its own way.
|
|
437
|
+
if (text !== null && sameJson(text, r.config)) return null
|
|
438
|
+
const rel = relPosix(root, r.file)
|
|
439
|
+
return text === null
|
|
440
|
+
? `"${r.name}" has no ${rel} — run 'vx lock'`
|
|
441
|
+
: `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
for (const f of stale) if (f !== null) failures.push(f)
|
|
445
|
+
// A copy left beside a config since deleted shows reviewers tasks
|
|
446
|
+
// nothing runs. vx never deletes a committed file; it names it.
|
|
447
|
+
const orphans = await Promise.all(
|
|
448
|
+
check.all
|
|
449
|
+
.filter((m) => m.configPath === null)
|
|
450
|
+
.map(async (m) => {
|
|
451
|
+
const file = path.join(m.dir, REVIEW_FILE)
|
|
452
|
+
return (await Bun.file(file).exists())
|
|
453
|
+
? `${relPosix(root, file)} has no vx.config beside it — delete it`
|
|
454
|
+
: null
|
|
455
|
+
}),
|
|
456
|
+
)
|
|
457
|
+
for (const f of orphans) if (f !== null) failures.push(f)
|
|
458
|
+
}
|
|
459
|
+
for (const dir of Object.keys(lock.projects)) {
|
|
460
|
+
if (!Object.hasOwn(check.fresh.projects, dir)) {
|
|
461
|
+
failures.push(`the lock holds ${dir}, which has no vx.config now — run 'vx lock'`)
|
|
228
462
|
}
|
|
229
463
|
}
|
|
230
464
|
if (json) return answer(failures)
|
|
231
465
|
if (failures.length > 0) {
|
|
232
|
-
for (const f of failures)
|
|
466
|
+
for (const f of failures) sayNotice('error', 'vx lock --check', f)
|
|
233
467
|
return 1
|
|
234
468
|
}
|
|
235
469
|
const n = metas.length
|
|
236
470
|
const audited = `${n} project${n === 1 ? '' : 's'}`
|
|
237
|
-
const skipped = bare === 0 ? '' : `;
|
|
238
|
-
|
|
471
|
+
const skipped = bare.length === 0 ? '' : `; not audited, no vx.config: ${bareNames(bare)}`
|
|
472
|
+
ui.print('out', ui.status('ok', 'vx lock --check', `up to date (${audited}${skipped})`))
|
|
239
473
|
return 0
|
|
240
474
|
}
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
76
76
|
lines.push('')
|
|
77
77
|
lines.push(summary.join(', ') + '.')
|
|
78
78
|
|
|
79
|
-
// `--download` is invisible without this: the eligibility gate silently
|
|
79
|
+
// `--cache-download` is invisible without this: the eligibility gate silently
|
|
80
80
|
// keeps producers eager, so a user who asked for `none` and got no
|
|
81
81
|
// deferral has nothing to read. Say what WOULD stay remote, and name the
|
|
82
82
|
// refusals when nothing would.
|
|
@@ -118,9 +118,11 @@ function describeReason(r: AffectedReason): string {
|
|
|
118
118
|
? `a ^ edge passes changed package ${r.project}`
|
|
119
119
|
: r.kind === 'input'
|
|
120
120
|
? `${r.file} changed (an input)`
|
|
121
|
-
: r.
|
|
122
|
-
? `${r.file} changed (
|
|
123
|
-
:
|
|
121
|
+
: r.kind === 'workspace'
|
|
122
|
+
? `${r.file} changed (workspace-wide)`
|
|
123
|
+
: r.file !== undefined
|
|
124
|
+
? `${r.file} changed (in its project)`
|
|
125
|
+
: 'its project changed as a whole'
|
|
124
126
|
return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -169,7 +171,7 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
|
|
|
169
171
|
}),
|
|
170
172
|
...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
|
|
171
173
|
// The gate's refusals belong on the SCRIPTING surface too: a CI job
|
|
172
|
-
// asking "did --download=none actually defer anything, and if not
|
|
174
|
+
// asking "did --cache-download=none actually defer anything, and if not
|
|
173
175
|
// why" reads this, not the human table. (This object enumerates its
|
|
174
176
|
// fields deliberately — the plan's internal shape is not the wire —
|
|
175
177
|
// which is exactly why a new PlannedTask field does not appear here
|