@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cli/lock.ts
CHANGED
|
@@ -7,35 +7,41 @@ import { open } from 'node:fs/promises'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import type { ProjectConfig } from '../config.js'
|
|
9
9
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
10
|
-
import { buildTaskGraph, expandRequested } from '../graph/index.js'
|
|
11
|
-
import {
|
|
12
|
-
applyGraphStage,
|
|
13
|
-
discoverProjects,
|
|
14
|
-
hasHook,
|
|
15
|
-
isDefaultBuild,
|
|
16
|
-
loadProjects,
|
|
17
|
-
} from '../orchestrator/index.js'
|
|
10
|
+
import { buildTaskGraph, expandRequested, isDefaultBuild } from '../graph/index.js'
|
|
11
|
+
import { applyGraphStage, discoverProjects, hasHook, loadProjects } from '../orchestrator/index.js'
|
|
18
12
|
import { resolveSetting, runRules } from '../settings.js'
|
|
19
13
|
import {
|
|
20
14
|
refusalNotice,
|
|
21
15
|
DOT_VX_GITATTRIBUTES,
|
|
16
|
+
fsRefusalHint,
|
|
17
|
+
isFsRefusal,
|
|
18
|
+
isOutOfFds,
|
|
22
19
|
isUserError,
|
|
20
|
+
maskedLine,
|
|
23
21
|
relPosix,
|
|
24
22
|
type RefusalParts,
|
|
23
|
+
type UserError,
|
|
25
24
|
secretMask,
|
|
25
|
+
type SecretMask,
|
|
26
26
|
ui,
|
|
27
27
|
sayNotice,
|
|
28
|
+
refuseSymlink,
|
|
29
|
+
writeNoFollow,
|
|
28
30
|
} from '../util/index.js'
|
|
29
31
|
import {
|
|
32
|
+
ConfigsRefused,
|
|
30
33
|
findProjectConfigs,
|
|
31
34
|
findWorkspaceRoot,
|
|
35
|
+
REVIEW_FILE,
|
|
32
36
|
loadProjectConfigs,
|
|
33
37
|
LOCKFILE_NAME,
|
|
34
38
|
LOCKFILE_VERSION,
|
|
35
39
|
lockKey,
|
|
36
40
|
readLockfile,
|
|
37
41
|
buildPackageGraph,
|
|
42
|
+
checkBracketLiterals,
|
|
38
43
|
checkDependencyRules,
|
|
44
|
+
computeNestedProjectDirs,
|
|
39
45
|
writeLockfile,
|
|
40
46
|
type Lockfile,
|
|
41
47
|
type ProjectMeta,
|
|
@@ -107,7 +113,7 @@ export async function lockWorkspace(
|
|
|
107
113
|
): Promise<number> {
|
|
108
114
|
const check = await audit(root, warn)
|
|
109
115
|
if (check.broken !== null) {
|
|
110
|
-
notWritten(check.broken, check.brokenParts)
|
|
116
|
+
notWritten(check.broken, check.brokenParts, check.alsoBroken)
|
|
111
117
|
return 1
|
|
112
118
|
}
|
|
113
119
|
return await writeLock(root, check, say)
|
|
@@ -116,7 +122,7 @@ export async function lockWorkspace(
|
|
|
116
122
|
/**
|
|
117
123
|
* The lock as `vx lock` would write it, and the graph rules (`run.rules`)
|
|
118
124
|
* checked over every project and task the workspace declares: a run trusts
|
|
119
|
-
* the lock and does not check them (
|
|
125
|
+
* the lock and does not check them (2026-10-13). Every config is
|
|
120
126
|
* found (`findProjectConfigs`) and evaluated once; the projects, names and
|
|
121
127
|
* graph are the ones a run builds from that lock, the `discover` and
|
|
122
128
|
* `graph` stages included.
|
|
@@ -126,16 +132,21 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
126
132
|
const resolved = new Map<string, ProjectConfig>()
|
|
127
133
|
const out: GraphCheck = {
|
|
128
134
|
broken: null,
|
|
135
|
+
loaded: false,
|
|
129
136
|
fresh: { version: LOCKFILE_VERSION, projects: {} },
|
|
130
137
|
all: [],
|
|
131
138
|
tasked,
|
|
132
139
|
resolved,
|
|
133
140
|
reviewFiles: true,
|
|
141
|
+
strayReviews: [],
|
|
142
|
+
shadowed: [],
|
|
134
143
|
}
|
|
135
144
|
try {
|
|
136
145
|
const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
|
|
137
146
|
out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
|
|
138
|
-
const found = await findProjectConfigs(root)
|
|
147
|
+
const { configs: found, strayReviews, shadowed } = await findProjectConfigs(root)
|
|
148
|
+
out.strayReviews = strayReviews
|
|
149
|
+
out.shadowed = shadowed
|
|
139
150
|
// The one load that evaluates: every reader reads the lock this writes.
|
|
140
151
|
const configs = await loadProjectConfigs(found.map((f) => f.configPath))
|
|
141
152
|
// In the lock's form: a field left `undefined` is a key the stored JSON
|
|
@@ -166,6 +177,8 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
166
177
|
lock: true,
|
|
167
178
|
})
|
|
168
179
|
for (const entry of projects.values()) resolved.set(entry.name, entry.config)
|
|
180
|
+
out.loaded = true
|
|
181
|
+
checkBracketLiterals(root, projects.values())
|
|
169
182
|
const names = new Set<string>()
|
|
170
183
|
for (const entry of projects.values()) {
|
|
171
184
|
const tasks = Object.entries(entry.config.tasks ?? {})
|
|
@@ -182,12 +195,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
182
195
|
buildPackageGraph(out.all, taskEdgesFrom(projects)),
|
|
183
196
|
)
|
|
184
197
|
const rules = runRules(workspaceConfig)
|
|
198
|
+
const nameOf = new Map(out.all.map((m) => [m.dir, m.name]))
|
|
185
199
|
const nodes = buildTaskGraph({
|
|
186
200
|
projects,
|
|
187
201
|
packageGraph: buildPackageGraph(out.all),
|
|
188
202
|
requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
|
|
189
203
|
workspaceRoot: root,
|
|
190
204
|
rules,
|
|
205
|
+
nestedProjects: new Map(
|
|
206
|
+
[...computeNestedProjectDirs(out.all)].map(([name, dirs]) => [
|
|
207
|
+
name,
|
|
208
|
+
dirs.map((dir) => ({ name: nameOf.get(dir)!, dir })),
|
|
209
|
+
]),
|
|
210
|
+
),
|
|
191
211
|
})
|
|
192
212
|
if (hasHook(plugins, 'graph'))
|
|
193
213
|
await applyGraphStage(plugins, nodes, {
|
|
@@ -199,7 +219,12 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
199
219
|
})
|
|
200
220
|
} catch (err) {
|
|
201
221
|
if (isUserError(err))
|
|
202
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
...out,
|
|
224
|
+
broken: err.message,
|
|
225
|
+
...(err.parts ? { brokenParts: err.parts } : {}),
|
|
226
|
+
...(err instanceof ConfigsRefused ? { alsoBroken: err.all.slice(1) } : {}),
|
|
227
|
+
}
|
|
203
228
|
throw err
|
|
204
229
|
}
|
|
205
230
|
return out
|
|
@@ -209,6 +234,10 @@ interface GraphCheck {
|
|
|
209
234
|
/** A graph rule's refusal, or a config that does not load; null when sound. */
|
|
210
235
|
broken: string | null
|
|
211
236
|
brokenParts?: RefusalParts
|
|
237
|
+
/** Every config loaded and resolved: `broken`, if set, is a graph rule's refusal. */
|
|
238
|
+
loaded: boolean
|
|
239
|
+
/** The other configs that do not load, past `broken`. */
|
|
240
|
+
alsoBroken?: readonly UserError[]
|
|
212
241
|
/** The lock the configs evaluate to here. */
|
|
213
242
|
fresh: Lockfile
|
|
214
243
|
/** Every project, as a run discovers them from `fresh`. */
|
|
@@ -219,6 +248,10 @@ interface GraphCheck {
|
|
|
219
248
|
resolved: Map<string, ProjectConfig>
|
|
220
249
|
/** `lock.reviewFiles`. */
|
|
221
250
|
reviewFiles: boolean
|
|
251
|
+
/** Review copies in a directory no config is in (`findProjectConfigs`). */
|
|
252
|
+
strayReviews: string[]
|
|
253
|
+
/** Config files a higher-precedence one beside them hides (`findProjectConfigs`). */
|
|
254
|
+
shadowed: { file: string; read: string }[]
|
|
222
255
|
}
|
|
223
256
|
|
|
224
257
|
function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
|
|
@@ -237,14 +270,38 @@ export async function lockAfterMigration(root: string): Promise<{ code: number;
|
|
|
237
270
|
return { code, said }
|
|
238
271
|
} catch (err) {
|
|
239
272
|
if (!isUserError(err)) throw err
|
|
240
|
-
notWritten(err.message, err.parts)
|
|
273
|
+
notWritten(err.message, err.parts, err instanceof ConfigsRefused ? err.all.slice(1) : [])
|
|
241
274
|
return { code: 1, said }
|
|
242
275
|
}
|
|
243
276
|
}
|
|
244
277
|
|
|
245
|
-
/**
|
|
246
|
-
|
|
247
|
-
|
|
278
|
+
/** Refusals shown in full past the first; the rest are counted. */
|
|
279
|
+
const ALSO_SHOWN = 4
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The lock refused: the refusal's own parts, or its config line and frame,
|
|
283
|
+
* or its message; then each other config that does not load, so one run
|
|
284
|
+
* names what a repo must fix.
|
|
285
|
+
*/
|
|
286
|
+
function notWritten(
|
|
287
|
+
message: string,
|
|
288
|
+
parts: RefusalParts | undefined,
|
|
289
|
+
also: readonly UserError[] = [],
|
|
290
|
+
): void {
|
|
291
|
+
// Masked: a config's own throw is its author's text, and quotes what
|
|
292
|
+
// it read (L-11). bin.ts masks what reaches it; this is printed here.
|
|
293
|
+
ui.print(
|
|
294
|
+
'err',
|
|
295
|
+
refusalNotice('vx lock', maskedLine(message), parts, { headline: 'lock not written' }),
|
|
296
|
+
)
|
|
297
|
+
for (const e of also.slice(0, ALSO_SHOWN))
|
|
298
|
+
ui.print(
|
|
299
|
+
'err',
|
|
300
|
+
refusalNotice('vx lock', maskedLine(e.message), e.parts, { headline: 'also refused' }),
|
|
301
|
+
)
|
|
302
|
+
const more = also.length - ALSO_SHOWN
|
|
303
|
+
if (more > 0)
|
|
304
|
+
sayNotice('error', 'vx lock', `${more} more configs refused; fix these, then run vx lock again`)
|
|
248
305
|
}
|
|
249
306
|
|
|
250
307
|
/** Names shown per note: a turbo() repo can have hundreds of bare projects. */
|
|
@@ -259,9 +316,6 @@ function bareNote(bare: readonly string[]): string {
|
|
|
259
316
|
return bare.length === 0 ? '' : ` (no vx.config, so not locked: ${bareNames(bare)})`
|
|
260
317
|
}
|
|
261
318
|
|
|
262
|
-
/** The name `vx lock` writes each project's review copy under. */
|
|
263
|
-
const REVIEW_FILE = 'vx.lock.json'
|
|
264
|
-
|
|
265
319
|
interface Review {
|
|
266
320
|
name: string
|
|
267
321
|
file: string
|
|
@@ -331,16 +385,69 @@ async function writeLock(
|
|
|
331
385
|
})
|
|
332
386
|
return 1
|
|
333
387
|
}
|
|
388
|
+
// The copies first: one that cannot be written leaves the lock as it was,
|
|
389
|
+
// not a new lock beside stale copies and a stack.
|
|
390
|
+
for (const r of reviews) refuseSymlink(r.file)
|
|
391
|
+
const blocked = (
|
|
392
|
+
await Promise.all(
|
|
393
|
+
reviews.map((r) =>
|
|
394
|
+
writeNoFollow(r.file, r.text).then(
|
|
395
|
+
() => null,
|
|
396
|
+
(err: unknown): { file: string; err: NodeJS.ErrnoException } => {
|
|
397
|
+
if (typeof (err as NodeJS.ErrnoException).code !== 'string' || isOutOfFds(err))
|
|
398
|
+
throw err
|
|
399
|
+
return { file: r.file, err: err as NodeJS.ErrnoException }
|
|
400
|
+
},
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
).filter((b) => b !== null)
|
|
405
|
+
if (blocked.length > 0) {
|
|
406
|
+
const first = blocked[0]!
|
|
407
|
+
notWritten('', {
|
|
408
|
+
headline: `${blocked.length === 1 ? 'a review copy' : 'review copies'} could not be written`,
|
|
409
|
+
// The code and the copy's path: the message names the temp the write renames.
|
|
410
|
+
what: blocked.map((b) => `${b.err.code}: ${relPosix(root, b.file)}`),
|
|
411
|
+
fix: isFsRefusal(first.err)
|
|
412
|
+
? fsRefusalHint(first.err)
|
|
413
|
+
: `remove what stands at ${relPosix(root, first.file)}, then run vx lock again`,
|
|
414
|
+
})
|
|
415
|
+
return 1
|
|
416
|
+
}
|
|
334
417
|
await writeLockfile(root, check.fresh)
|
|
335
|
-
await
|
|
336
|
-
...reviews.map((r) => Bun.write(r.file, r.text)),
|
|
337
|
-
writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
|
|
338
|
-
])
|
|
418
|
+
await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
|
|
339
419
|
const n = metas.length
|
|
340
420
|
say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
|
|
421
|
+
// A second config beside the one read was edited and nothing changed.
|
|
422
|
+
warnShadowed(root, check, 'vx lock')
|
|
423
|
+
// Named here too: a lock that wrote clean failed the `--check` after it.
|
|
424
|
+
if (check.reviewFiles)
|
|
425
|
+
for (const file of await strayCopies(check)) sayNotice('warn', 'vx lock', strayLine(root, file))
|
|
341
426
|
return 0
|
|
342
427
|
}
|
|
343
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Review copies with no config beside them, sorted. One left beside a
|
|
431
|
+
* config since deleted shows reviewers tasks nothing runs, and keys the
|
|
432
|
+
* `**` of a project around it. vx never deletes a committed file; it
|
|
433
|
+
* names it. A project kept without a config (a plugin's) is checked on
|
|
434
|
+
* disk; any other directory is what discovery listed.
|
|
435
|
+
*/
|
|
436
|
+
async function strayCopies(check: GraphCheck): Promise<string[]> {
|
|
437
|
+
const kept = await Promise.all(
|
|
438
|
+
check.all
|
|
439
|
+
.filter((m) => m.configPath === null)
|
|
440
|
+
.map(async (m) => {
|
|
441
|
+
const file = path.join(m.dir, REVIEW_FILE)
|
|
442
|
+
return (await Bun.file(file).exists()) ? file : null
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
return [...new Set([...kept.filter((f) => f !== null), ...check.strayReviews])].sort()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const strayLine = (root: string, file: string): string =>
|
|
449
|
+
`${relPosix(root, file)} has no vx.config beside it — delete it`
|
|
450
|
+
|
|
344
451
|
/**
|
|
345
452
|
* Each place an evaluated config holds the value of a secret in this
|
|
346
453
|
* environment, by the rule output masking uses (a secret-named variable,
|
|
@@ -354,17 +461,20 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
|
|
|
354
461
|
for (const task of Object.values(config.tasks ?? {}))
|
|
355
462
|
for (const n of task?.exec?.env?.secret ?? []) named.add(n)
|
|
356
463
|
}
|
|
357
|
-
|
|
464
|
+
// Each variable's own mask: what the output masks of it, a URL's
|
|
465
|
+
// password and a key's lines too, not only the whole value.
|
|
466
|
+
const secrets: [mask: SecretMask, name: string][] = []
|
|
358
467
|
for (const [name, value] of Object.entries(process.env)) {
|
|
359
|
-
|
|
360
|
-
|
|
468
|
+
const mask =
|
|
469
|
+
value === undefined ? null : secretMask([{ [name]: value }], named.has(name) ? [name] : [])
|
|
470
|
+
if (mask !== null) secrets.push([mask, name])
|
|
361
471
|
}
|
|
362
472
|
if (secrets.length === 0) return []
|
|
363
473
|
const found: Array<{ at: string; name: string }> = []
|
|
364
474
|
const walk = (project: string, at: string, v: unknown): void => {
|
|
365
475
|
if (typeof v === 'string') {
|
|
366
|
-
for (const [
|
|
367
|
-
if (
|
|
476
|
+
for (const [mask, name] of secrets)
|
|
477
|
+
if (mask.spans(v).length > 0) found.push({ at: `${project}: ${at} holds $${name}`, name })
|
|
368
478
|
} else if (Array.isArray(v)) {
|
|
369
479
|
v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
|
|
370
480
|
} else if (v !== null && typeof v === 'object') {
|
|
@@ -375,6 +485,16 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
|
|
|
375
485
|
return found
|
|
376
486
|
}
|
|
377
487
|
|
|
488
|
+
/** A second config beside the one read: edited, it changes nothing. */
|
|
489
|
+
function warnShadowed(root: string, check: GraphCheck, verb: string): void {
|
|
490
|
+
for (const { file, read } of check.shadowed)
|
|
491
|
+
sayNotice(
|
|
492
|
+
'warn',
|
|
493
|
+
verb,
|
|
494
|
+
`${relPosix(root, file)} is not read: ${path.basename(read)} beside it is — delete one`,
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
378
498
|
/**
|
|
379
499
|
* Audit the lock: find and re-evaluate every config in the CURRENT
|
|
380
500
|
* environment and deep-compare it with the locked object. That sees an
|
|
@@ -383,6 +503,9 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
|
|
|
383
503
|
*/
|
|
384
504
|
async function checkLock(root: string, json: boolean): Promise<number> {
|
|
385
505
|
const check = await audit(root, warnToStderr)
|
|
506
|
+
// A warning, as `vx lock` says it: the CI gate passed beside a hidden
|
|
507
|
+
// config whose edit changed nothing.
|
|
508
|
+
warnShadowed(root, check, 'vx lock --check')
|
|
386
509
|
const { broken } = check
|
|
387
510
|
const metas = configured(check.all)
|
|
388
511
|
const bare = bareProjects(check.all, check.tasked)
|
|
@@ -398,7 +521,16 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
398
521
|
}
|
|
399
522
|
return problems.length === 0 ? 0 : 1
|
|
400
523
|
}
|
|
401
|
-
|
|
524
|
+
// A lock that does not read is drift `vx lock` fixes, as a missing one
|
|
525
|
+
// is: the audit answers it in its own shape, not as a refusal.
|
|
526
|
+
let lock: Lockfile | null
|
|
527
|
+
try {
|
|
528
|
+
lock = await readLockfile(root)
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (!isUserError(err)) throw err
|
|
531
|
+
if (!json) sayNotice('error', 'vx lock --check', err.message)
|
|
532
|
+
return answer([err.message])
|
|
533
|
+
}
|
|
402
534
|
if (!lock) {
|
|
403
535
|
const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
|
|
404
536
|
if (!json) sayNotice('error', 'vx lock --check', problem)
|
|
@@ -413,7 +545,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
413
545
|
locked === undefined
|
|
414
546
|
? `${rel} is not in the lock — run 'vx lock'`
|
|
415
547
|
: locked.file !== fresh.file
|
|
416
|
-
? `${rel} is not the ${locked.file} the lock holds for ${dir} — run 'vx lock'`
|
|
548
|
+
? `${rel} is not the ${locked.file} the lock holds for ${dir === '.' ? 'the workspace root project' : dir} — run 'vx lock'`
|
|
417
549
|
: !Bun.deepEquals(fresh.config, locked.config, true)
|
|
418
550
|
? `${rel} evaluates differently from the lock here — its config, a file ` +
|
|
419
551
|
`it imports, or the environment it reads changed; run 'vx lock'`
|
|
@@ -422,47 +554,60 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
422
554
|
failures.push(problem)
|
|
423
555
|
drifted.add(dir)
|
|
424
556
|
}
|
|
425
|
-
if (broken !== null) failures.push(broken)
|
|
426
|
-
|
|
557
|
+
if (broken !== null && check.loaded) failures.push(broken)
|
|
558
|
+
// Each config that does not load, as `vx lock` names them: a CI fixing
|
|
559
|
+
// one at a time re-ran the gate once per broken config.
|
|
560
|
+
const refused: { message: string; parts?: RefusalParts }[] =
|
|
561
|
+
broken === null || check.loaded
|
|
562
|
+
? []
|
|
563
|
+
: [
|
|
564
|
+
{ message: broken, ...(check.brokenParts ? { parts: check.brokenParts } : {}) },
|
|
565
|
+
...(check.alsoBroken ?? []).map((e) => ({
|
|
566
|
+
message: e.message,
|
|
567
|
+
...(e.parts ? { parts: e.parts } : {}),
|
|
568
|
+
})),
|
|
569
|
+
]
|
|
570
|
+
if (check.reviewFiles && check.loaded) {
|
|
427
571
|
// A project the lock already fails is named once.
|
|
428
572
|
const stale = await Promise.all(
|
|
429
573
|
reviewsOf(
|
|
430
574
|
metas.filter((m) => !drifted.has(lockKey(root, m.dir))),
|
|
431
575
|
check.resolved,
|
|
432
576
|
).map(async (r) => {
|
|
577
|
+
// Only a missing copy is "has no": a directory in its place, or one
|
|
578
|
+
// this user cannot read, read as missing too.
|
|
433
579
|
const text = await Bun.file(r.file)
|
|
434
580
|
.text()
|
|
435
|
-
.catch(() => null)
|
|
581
|
+
.catch((err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? null : err))
|
|
436
582
|
// Compared as JSON: a formatter may lay the copy out its own way.
|
|
437
|
-
if (text
|
|
583
|
+
if (typeof text === 'string' && sameJson(text, r.config)) return null
|
|
438
584
|
const rel = relPosix(root, r.file)
|
|
439
585
|
return text === null
|
|
440
586
|
? `"${r.name}" has no ${rel} — run 'vx lock'`
|
|
441
|
-
:
|
|
587
|
+
: typeof text !== 'string'
|
|
588
|
+
? `"${r.name}" (${rel}) could not be read (${text.code ?? text.message}) — remove it and run 'vx lock'`
|
|
589
|
+
: `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
|
|
442
590
|
}),
|
|
443
591
|
)
|
|
444
592
|
for (const f of stale) if (f !== null) failures.push(f)
|
|
445
|
-
|
|
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)
|
|
593
|
+
for (const file of await strayCopies(check)) failures.push(strayLine(root, file))
|
|
458
594
|
}
|
|
459
|
-
|
|
595
|
+
// A config that does not load evaluated none: every locked project
|
|
596
|
+
// read as gone, the healthy ones too.
|
|
597
|
+
for (const dir of check.loaded ? Object.keys(lock.projects) : []) {
|
|
460
598
|
if (!Object.hasOwn(check.fresh.projects, dir)) {
|
|
461
|
-
|
|
599
|
+
const what = dir === '.' ? 'the workspace root project' : dir
|
|
600
|
+
failures.push(`the lock holds ${what}, which has no vx.config now — run 'vx lock'`)
|
|
462
601
|
}
|
|
463
602
|
}
|
|
464
|
-
if (json)
|
|
465
|
-
|
|
603
|
+
if (json)
|
|
604
|
+
// Root-relative, as every other problem: the loader names the absolute path.
|
|
605
|
+
return answer([
|
|
606
|
+
...refused.map((r) => r.message.split(`${root}${path.sep}`).join('')),
|
|
607
|
+
...failures,
|
|
608
|
+
])
|
|
609
|
+
if (failures.length + refused.length > 0) {
|
|
610
|
+
for (const r of refused) ui.print('err', refusalNotice('vx lock --check', r.message, r.parts))
|
|
466
611
|
for (const f of failures) sayNotice('error', 'vx lock --check', f)
|
|
467
612
|
return 1
|
|
468
613
|
}
|