@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- 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 +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- 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 +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- 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 +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- 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-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- 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 +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -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/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- 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 +362 -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,44 @@
|
|
|
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
|
+
configErrorFrame,
|
|
21
|
+
DOT_VX_GITATTRIBUTES,
|
|
22
|
+
isUserError,
|
|
23
|
+
relPosix,
|
|
24
|
+
secretMask,
|
|
25
|
+
} from '../util/index.js'
|
|
26
|
+
import {
|
|
27
|
+
findProjectConfigs,
|
|
9
28
|
findWorkspaceRoot,
|
|
10
|
-
|
|
11
|
-
loadProjectConfig,
|
|
12
|
-
loadWorkspace,
|
|
29
|
+
loadProjectConfigs,
|
|
13
30
|
LOCKFILE_NAME,
|
|
14
31
|
LOCKFILE_VERSION,
|
|
32
|
+
lockKey,
|
|
15
33
|
readLockfile,
|
|
34
|
+
buildPackageGraph,
|
|
35
|
+
checkDependencyRules,
|
|
16
36
|
writeLockfile,
|
|
17
37
|
type Lockfile,
|
|
18
|
-
type LockfileEntry,
|
|
19
38
|
type ProjectMeta,
|
|
20
39
|
} from '../workspace/index.js'
|
|
21
|
-
import {
|
|
40
|
+
import { taskEdgesFrom } from './select.js'
|
|
41
|
+
import { loadCliWorkspace, warnToStderr } from './workspace-config.js'
|
|
22
42
|
|
|
23
43
|
interface LockArgs {
|
|
24
44
|
check: boolean
|
|
@@ -57,55 +77,238 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
57
77
|
`--format json answers --check only${seeHelp('lock')}`,
|
|
58
78
|
'VX_E_USAGE',
|
|
59
79
|
)
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
const root = await findWorkspaceRoot(process.cwd())
|
|
81
|
+
if (parsed.check) return await checkLock(root, parsed.format === 'json')
|
|
82
|
+
return await lockWorkspace(root)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Projects with tasks and no vx.config: a plugin's tasks, which load live
|
|
87
|
+
* in every run, so both verbs name them — `locked 0 project configs`
|
|
88
|
+
* on a turbo() repo read like an audit of something (2026-09-16). One with
|
|
89
|
+
* no task is left out: solidjs/solid's solid-ssr, which has no script Turbo
|
|
90
|
+
* runs, read as a project whose tasks come live from the plugin `project` stage.
|
|
91
|
+
*/
|
|
92
|
+
function bareProjects(all: readonly ProjectMeta[], tasked: ReadonlySet<string>): string[] {
|
|
93
|
+
return all.filter((m) => m.configPath === null && tasked.has(m.name)).map((m) => m.name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `vx lock`: find and evaluate every config, check the graph rules over
|
|
98
|
+
* every task, and write the lock: the only writer of it.
|
|
99
|
+
*/
|
|
100
|
+
export async function lockWorkspace(
|
|
101
|
+
root: string,
|
|
102
|
+
warn: (message: string) => void = warnToStderr,
|
|
103
|
+
): Promise<number> {
|
|
104
|
+
const check = await audit(root, warn)
|
|
105
|
+
if (check.broken !== null) {
|
|
106
|
+
process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(check.broken)}\n`)
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
109
|
+
return await writeLock(root, check)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The lock as `vx lock` would write it, and the graph rules (`run.rules`)
|
|
114
|
+
* checked over every project and task the workspace declares: a run trusts
|
|
115
|
+
* the lock and does not check them (owner, 2026-10-13). Every config is
|
|
116
|
+
* found (`findProjectConfigs`) and evaluated once; the projects, names and
|
|
117
|
+
* graph are the ones a run builds from that lock, the `discover` and
|
|
118
|
+
* `graph` stages included.
|
|
119
|
+
*/
|
|
120
|
+
async function audit(root: string, warn: (message: string) => void): Promise<GraphCheck> {
|
|
121
|
+
const tasked = new Set<string>()
|
|
122
|
+
const resolved = new Map<string, ProjectConfig>()
|
|
123
|
+
const out: GraphCheck = {
|
|
124
|
+
broken: null,
|
|
125
|
+
fresh: { version: LOCKFILE_VERSION, projects: {} },
|
|
126
|
+
all: [],
|
|
127
|
+
tasked,
|
|
128
|
+
resolved,
|
|
129
|
+
reviewFiles: true,
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
|
|
133
|
+
out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
|
|
134
|
+
const found = await findProjectConfigs(root)
|
|
135
|
+
// The one load that evaluates: every reader reads the lock this writes.
|
|
136
|
+
const configs = await loadProjectConfigs(found.map((f) => f.configPath))
|
|
137
|
+
// In the lock's form: a field left `undefined` is a key the stored JSON
|
|
138
|
+
// never has. Entries, not assignment: a directory named `__proto__` is
|
|
139
|
+
// a key of its own (D-152).
|
|
140
|
+
out.fresh.projects = Object.fromEntries(
|
|
141
|
+
found.map((f, i) => [
|
|
142
|
+
lockKey(root, f.dir),
|
|
143
|
+
{
|
|
144
|
+
file: path.basename(f.configPath),
|
|
145
|
+
config: JSON.parse(JSON.stringify(configs[i])) as ProjectConfig,
|
|
146
|
+
},
|
|
147
|
+
]),
|
|
148
|
+
)
|
|
149
|
+
// The stages edit what they are handed; the lock keeps what the configs say.
|
|
150
|
+
const view = structuredClone(out.fresh)
|
|
151
|
+
const lock = async (): Promise<Lockfile> => view
|
|
152
|
+
out.all = await discoverProjects(root, lock, plugins, cacheDir, warn)
|
|
153
|
+
const { projects } = await loadProjects({
|
|
154
|
+
workspaceRoot: root,
|
|
155
|
+
cacheDir,
|
|
156
|
+
plugins,
|
|
157
|
+
projectMetas: out.all,
|
|
158
|
+
seeds: 'all',
|
|
159
|
+
closure: false,
|
|
160
|
+
configs: lock,
|
|
161
|
+
warn,
|
|
162
|
+
})
|
|
163
|
+
for (const entry of projects.values()) resolved.set(entry.name, entry.config)
|
|
164
|
+
const names = new Set<string>()
|
|
165
|
+
for (const entry of projects.values()) {
|
|
166
|
+
const tasks = Object.entries(entry.config.tasks ?? {})
|
|
167
|
+
// Every project has a `build`; core's default one is nothing to freeze.
|
|
168
|
+
if (tasks.some(([, t]) => !isDefaultBuild(t))) tasked.add(entry.name)
|
|
169
|
+
for (const [t] of tasks) names.add(t)
|
|
170
|
+
}
|
|
171
|
+
const dependencyRules = workspaceConfig?.run?.rules?.dependencies ?? []
|
|
172
|
+
if (dependencyRules.length > 0)
|
|
173
|
+
checkDependencyRules(
|
|
174
|
+
dependencyRules,
|
|
175
|
+
[...projects.keys()],
|
|
176
|
+
new Map([...projects.values()].map((p) => [p.name, p.config.tags ?? []])),
|
|
177
|
+
buildPackageGraph(out.all, taskEdgesFrom(projects)),
|
|
178
|
+
)
|
|
179
|
+
const rules = runRules(workspaceConfig)
|
|
180
|
+
const nodes = buildTaskGraph({
|
|
181
|
+
projects,
|
|
182
|
+
packageGraph: buildPackageGraph(out.all),
|
|
183
|
+
requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
|
|
184
|
+
workspaceRoot: root,
|
|
185
|
+
rules,
|
|
186
|
+
})
|
|
187
|
+
if (hasHook(plugins, 'graph'))
|
|
188
|
+
await applyGraphStage(plugins, nodes, {
|
|
189
|
+
workspaceRoot: root,
|
|
190
|
+
cacheDir,
|
|
191
|
+
projectMetas: out.all,
|
|
192
|
+
warn,
|
|
193
|
+
rules,
|
|
194
|
+
})
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (isUserError(err)) return { ...out, broken: err.message }
|
|
197
|
+
throw err
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface GraphCheck {
|
|
203
|
+
/** A graph rule's refusal, or a config that does not load; null when sound. */
|
|
204
|
+
broken: string | null
|
|
205
|
+
/** The lock the configs evaluate to here. */
|
|
206
|
+
fresh: Lockfile
|
|
207
|
+
/** Every project, as a run discovers them from `fresh`. */
|
|
208
|
+
all: ProjectMeta[]
|
|
209
|
+
/** The projects that have a task. */
|
|
210
|
+
tasked: Set<string>
|
|
211
|
+
/** Each project's config as a run resolves it, plugins applied. */
|
|
212
|
+
resolved: Map<string, ProjectConfig>
|
|
213
|
+
/** `lock.reviewFiles`. */
|
|
214
|
+
reviewFiles: boolean
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
|
|
218
|
+
return all.filter((m): m is ConfiguredMeta => m.configPath !== null)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `vx init`'s last step: the lock, as `vx lock` writes it. A refusal (a
|
|
223
|
+
* graph rule, a config that does not load) is printed and leaves the
|
|
224
|
+
* written configs in place.
|
|
225
|
+
*/
|
|
226
|
+
export async function lockAfterMigration(root: string): Promise<number> {
|
|
227
|
+
try {
|
|
228
|
+
return await lockWorkspace(root)
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (!isUserError(err)) throw err
|
|
231
|
+
process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(err.message)}\n`)
|
|
232
|
+
return 1
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A config refusal as a run used to print it: path:line:col and the source line. */
|
|
237
|
+
function framed(message: string): string {
|
|
238
|
+
return configErrorFrame(message, process.cwd()) ?? message
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Names shown per note: a turbo() repo can have hundreds of bare projects. */
|
|
242
|
+
const BARE_SHOWN = 5
|
|
243
|
+
|
|
244
|
+
function bareNames(bare: readonly string[]): string {
|
|
245
|
+
const more = bare.length - BARE_SHOWN
|
|
246
|
+
return more > 0 ? `${bare.slice(0, BARE_SHOWN).join(', ')} and ${more} more` : bare.join(', ')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function bareNote(bare: readonly string[]): string {
|
|
250
|
+
return bare.length === 0 ? '' : ` (no vx.config, so not locked: ${bareNames(bare)})`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The name `vx lock` writes each project's review copy under. */
|
|
254
|
+
const REVIEW_FILE = 'vx.lock.json'
|
|
255
|
+
|
|
256
|
+
interface Review {
|
|
257
|
+
name: string
|
|
258
|
+
file: string
|
|
259
|
+
config: ProjectConfig
|
|
260
|
+
text: string
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Each configured project's review copy: its resolved config, pretty. */
|
|
264
|
+
function sameJson(text: string, config: ProjectConfig): boolean {
|
|
265
|
+
try {
|
|
266
|
+
return Bun.deepEquals(JSON.parse(text), JSON.parse(JSON.stringify(config)), true)
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function reviewsOf(metas: ConfiguredMeta[], resolved: Map<string, ProjectConfig>): Review[] {
|
|
273
|
+
return metas.flatMap((m) => {
|
|
274
|
+
const config = resolved.get(m.name)
|
|
275
|
+
if (config === undefined) return []
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
name: m.name,
|
|
279
|
+
file: path.join(path.dirname(m.configPath), REVIEW_FILE),
|
|
280
|
+
config,
|
|
281
|
+
text: `${JSON.stringify(config, null, 2)}\n`,
|
|
282
|
+
},
|
|
283
|
+
]
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Writes `text` to `file` unless it exists: one a user edited stays theirs. */
|
|
288
|
+
async function writeIfMissing(file: string, text: string): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const fh = await open(file, 'wx')
|
|
291
|
+
await fh.writeFile(text)
|
|
292
|
+
await fh.close()
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function writeLock(root: string, check: GraphCheck): Promise<number> {
|
|
299
|
+
const metas = configured(check.all)
|
|
300
|
+
const bare = bareProjects(check.all, check.tasked)
|
|
301
|
+
// By project name, as the refusal names them.
|
|
302
|
+
const projects: Record<string, ProjectConfig> = Object.fromEntries(
|
|
303
|
+
metas.map((m) => [m.name, check.fresh.projects[lockKey(root, m.dir)]!.config]),
|
|
107
304
|
)
|
|
305
|
+
const reviews = check.reviewFiles ? reviewsOf(metas, check.resolved) : []
|
|
306
|
+
// The review copies are committed too; a plugin's `project` stage can add
|
|
307
|
+
// a value the bare config lacks. A place both hold is named once.
|
|
108
308
|
const leaks = secretsIn(projects)
|
|
309
|
+
const named = new Set(leaks.map((l) => l.at))
|
|
310
|
+
for (const l of secretsIn(Object.fromEntries(reviews.map((r) => [r.name, r.config]))))
|
|
311
|
+
if (!named.has(l.at)) leaks.push(l)
|
|
109
312
|
if (leaks.length > 0) {
|
|
110
313
|
process.stderr.write(
|
|
111
314
|
`vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
|
|
@@ -114,8 +317,11 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
114
317
|
)
|
|
115
318
|
return 1
|
|
116
319
|
}
|
|
117
|
-
|
|
118
|
-
await
|
|
320
|
+
await writeLockfile(root, check.fresh)
|
|
321
|
+
await Promise.all([
|
|
322
|
+
...reviews.map((r) => Bun.write(r.file, r.text)),
|
|
323
|
+
writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
|
|
324
|
+
])
|
|
119
325
|
const n = metas.length
|
|
120
326
|
process.stdout.write(
|
|
121
327
|
`vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
|
|
@@ -128,12 +334,12 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
128
334
|
* environment, by the rule output masking uses (a secret-named variable,
|
|
129
335
|
* or one a task lists in `exec.env.secret`). The lock is committed, so
|
|
130
336
|
* such a value would be published, and masking it would freeze a `***`
|
|
131
|
-
* that
|
|
337
|
+
* that every run runs (L-42).
|
|
132
338
|
*/
|
|
133
|
-
function secretsIn(projects: Record<string,
|
|
339
|
+
function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string; name: string }> {
|
|
134
340
|
const named = new Set<string>()
|
|
135
|
-
for (const
|
|
136
|
-
for (const task of Object.values(
|
|
341
|
+
for (const config of Object.values(projects)) {
|
|
342
|
+
for (const task of Object.values(config.tasks ?? {}))
|
|
137
343
|
for (const n of task?.exec?.env?.secret ?? []) named.add(n)
|
|
138
344
|
}
|
|
139
345
|
const secrets: [value: string, name: string][] = []
|
|
@@ -153,30 +359,27 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
|
|
|
153
359
|
for (const [k, x] of Object.entries(v)) walk(project, at === '' ? k : `${at}.${k}`, x)
|
|
154
360
|
}
|
|
155
361
|
}
|
|
156
|
-
for (const [project,
|
|
362
|
+
for (const [project, config] of Object.entries(projects)) walk(project, '', config)
|
|
157
363
|
return found
|
|
158
364
|
}
|
|
159
365
|
|
|
160
366
|
/**
|
|
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.
|
|
367
|
+
* Audit the lock: find and re-evaluate every config in the CURRENT
|
|
368
|
+
* environment and deep-compare it with the locked object. That sees an
|
|
369
|
+
* edit to the config, to a file it imports, and to the environment it
|
|
370
|
+
* reads alike, and a config added or removed since.
|
|
167
371
|
*/
|
|
168
|
-
async function checkLock(
|
|
169
|
-
root
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
): Promise<number> {
|
|
372
|
+
async function checkLock(root: string, json: boolean): Promise<number> {
|
|
373
|
+
const check = await audit(root, warnToStderr)
|
|
374
|
+
const { broken } = check
|
|
375
|
+
const metas = configured(check.all)
|
|
376
|
+
const bare = bareProjects(check.all, check.tasked)
|
|
174
377
|
const answer = (problems: string[]): number => {
|
|
175
378
|
if (json) {
|
|
176
379
|
const out = {
|
|
177
380
|
upToDate: problems.length === 0,
|
|
178
381
|
audited: metas.length,
|
|
179
|
-
notAudited: bare,
|
|
382
|
+
notAudited: bare.length,
|
|
180
383
|
problems,
|
|
181
384
|
}
|
|
182
385
|
process.stdout.write(`${JSON.stringify(out)}\n`)
|
|
@@ -189,42 +392,61 @@ async function checkLock(
|
|
|
189
392
|
if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
|
|
190
393
|
return answer([problem])
|
|
191
394
|
}
|
|
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
|
-
)
|
|
395
|
+
const failures: string[] = []
|
|
396
|
+
const drifted = new Set<string>()
|
|
397
|
+
for (const [dir, fresh] of Object.entries(check.fresh.projects)) {
|
|
398
|
+
const rel = dir === '.' ? fresh.file : `${dir}/${fresh.file}`
|
|
399
|
+
const locked = Object.hasOwn(lock.projects, dir) ? lock.projects[dir] : undefined
|
|
400
|
+
const problem =
|
|
401
|
+
locked === undefined
|
|
402
|
+
? `${rel} is not in the lock — run 'vx lock'`
|
|
403
|
+
: locked.file !== fresh.file
|
|
404
|
+
? `${rel} is not the ${locked.file} the lock holds for ${dir} — run 'vx lock'`
|
|
405
|
+
: !Bun.deepEquals(fresh.config, locked.config, true)
|
|
406
|
+
? `${rel} evaluates differently from the lock here — its config, a file ` +
|
|
407
|
+
`it imports, or the environment it reads changed; run 'vx lock'`
|
|
408
|
+
: null
|
|
409
|
+
if (problem === null) continue
|
|
410
|
+
failures.push(problem)
|
|
411
|
+
drifted.add(dir)
|
|
412
|
+
}
|
|
413
|
+
if (broken !== null) failures.push(broken)
|
|
414
|
+
if (check.reviewFiles && broken === null) {
|
|
415
|
+
// A project the lock already fails is named once.
|
|
416
|
+
const stale = await Promise.all(
|
|
417
|
+
reviewsOf(
|
|
418
|
+
metas.filter((m) => !drifted.has(lockKey(root, m.dir))),
|
|
419
|
+
check.resolved,
|
|
420
|
+
).map(async (r) => {
|
|
421
|
+
const text = await Bun.file(r.file)
|
|
422
|
+
.text()
|
|
423
|
+
.catch(() => null)
|
|
424
|
+
// Compared as JSON: a formatter may lay the copy out its own way.
|
|
425
|
+
if (text !== null && sameJson(text, r.config)) return null
|
|
426
|
+
const rel = relPosix(root, r.file)
|
|
427
|
+
return text === null
|
|
428
|
+
? `"${r.name}" has no ${rel} — run 'vx lock'`
|
|
429
|
+
: `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
|
|
430
|
+
}),
|
|
431
|
+
)
|
|
432
|
+
for (const f of stale) if (f !== null) failures.push(f)
|
|
433
|
+
// A copy left beside a config since deleted shows reviewers tasks
|
|
434
|
+
// nothing runs. vx never deletes a committed file; it names it.
|
|
435
|
+
const orphans = await Promise.all(
|
|
436
|
+
check.all
|
|
437
|
+
.filter((m) => m.configPath === null)
|
|
438
|
+
.map(async (m) => {
|
|
439
|
+
const file = path.join(m.dir, REVIEW_FILE)
|
|
440
|
+
return (await Bun.file(file).exists())
|
|
441
|
+
? `${relPosix(root, file)} has no vx.config beside it — delete it`
|
|
442
|
+
: null
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
for (const f of orphans) if (f !== null) failures.push(f)
|
|
446
|
+
}
|
|
447
|
+
for (const dir of Object.keys(lock.projects)) {
|
|
448
|
+
if (!Object.hasOwn(check.fresh.projects, dir)) {
|
|
449
|
+
failures.push(`the lock holds ${dir}, which has no vx.config now — run 'vx lock'`)
|
|
228
450
|
}
|
|
229
451
|
}
|
|
230
452
|
if (json) return answer(failures)
|
|
@@ -234,7 +456,7 @@ async function checkLock(
|
|
|
234
456
|
}
|
|
235
457
|
const n = metas.length
|
|
236
458
|
const audited = `${n} project${n === 1 ? '' : 's'}`
|
|
237
|
-
const skipped = bare === 0 ? '' : `;
|
|
459
|
+
const skipped = bare.length === 0 ? '' : `; not audited, no vx.config: ${bareNames(bare)}`
|
|
238
460
|
process.stdout.write(`vx lock --check: up to date (${audited}${skipped})\n`)
|
|
239
461
|
return 0
|
|
240
462
|
}
|
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.
|
|
@@ -169,7 +169,7 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
|
|
|
169
169
|
}),
|
|
170
170
|
...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
|
|
171
171
|
// The gate's refusals belong on the SCRIPTING surface too: a CI job
|
|
172
|
-
// asking "did --download=none actually defer anything, and if not
|
|
172
|
+
// asking "did --cache-download=none actually defer anything, and if not
|
|
173
173
|
// why" reads this, not the human table. (This object enumerates its
|
|
174
174
|
// fields deliberately — the plan's internal shape is not the wire —
|
|
175
175
|
// which is exactly why a new PlannedTask field does not appear here
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
// before this is asked, so a plugin cannot shadow `run`.
|
|
9
9
|
|
|
10
10
|
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
11
|
-
import {
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
import { findWorkspaceRoot, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
|
|
11
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
14
12
|
import { machineParallelism, maskedLine } from '../util/index.js'
|
|
13
|
+
import { resolveSetting } from '../settings.js'
|
|
15
14
|
|
|
16
15
|
export interface ResolvedPluginCommand {
|
|
17
16
|
plugin: VxPlugin
|
|
@@ -53,11 +52,6 @@ async function workspacePlugins(
|
|
|
53
52
|
} catch {
|
|
54
53
|
return null
|
|
55
54
|
}
|
|
56
|
-
// Plugins are declared only in a workspace file. Without one, the load
|
|
57
|
-
// below is the orchestrator's import graph (~50 ms) for an empty list,
|
|
58
|
-
// paid by `vx help` and by the `vx completions` a shell sources at start.
|
|
59
|
-
if (!WORKSPACE_CONFIG_FILENAMES.some((f) => existsSync(path.join(workspaceRoot, f))))
|
|
60
|
-
return { workspaceRoot, cacheDir: '', concurrency: 0, plugins: [] }
|
|
61
55
|
let ws
|
|
62
56
|
try {
|
|
63
57
|
const { loadCliWorkspace } = await import('./workspace-config.js')
|
|
@@ -69,7 +63,9 @@ async function workspacePlugins(
|
|
|
69
63
|
workspaceRoot,
|
|
70
64
|
cacheDir: ws.cacheDir,
|
|
71
65
|
// What run.ts takes when no --concurrency is given.
|
|
72
|
-
concurrency:
|
|
66
|
+
concurrency:
|
|
67
|
+
(resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
|
|
68
|
+
machineParallelism(),
|
|
73
69
|
plugins: ws.plugins,
|
|
74
70
|
}
|
|
75
71
|
}
|