@vzn/vx 0.0.632 → 0.0.633

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.
@@ -12,6 +12,7 @@
12
12
  // (item 1020). A save that fails in EVERY layer still throws, so the
13
13
  // caller's own "cache save failed" line stays the one word on it.
14
14
 
15
+ import { statSync } from 'node:fs'
15
16
  import type {
16
17
  Cache,
17
18
  CacheEntry,
@@ -248,6 +249,14 @@ export class ChainedCache implements CacheLayer {
248
249
  return this.owner(hash).outputsPath(hash)
249
250
  }
250
251
 
252
+ /** A layer that cannot say keeps a file at `outputsPath`, or nothing. */
253
+ artifactSize(hash: string): number | undefined {
254
+ const owner = this.owner(hash)
255
+ return owner.artifactSize !== undefined
256
+ ? owner.artifactSize(hash)
257
+ : statSync(owner.outputsPath(hash), { throwIfNoEntry: false })?.size
258
+ }
259
+
251
260
  prune(options: PruneOptions): Promise<PruneResult> {
252
261
  return this.layers[0]!.prune(options)
253
262
  }
@@ -92,6 +92,11 @@ const ALWAYS_IGNORE = [
92
92
  // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
93
93
  // 60 MB mid-write under the same race (2026-09-29).
94
94
  '**/.????????????????-????????.tmp/**',
95
+ // vx's own restore temp (archive.ts), live only while an artifact lands
96
+ // beside its target. A key taken meanwhile listed it, the rename removed
97
+ // it, and every task reading that directory was withheld its save
98
+ // (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
99
+ '**/.vx-tmp-*',
95
100
  ]
96
101
 
97
102
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
@@ -267,7 +267,7 @@ export interface InvocationRecord {
267
267
  }
268
268
 
269
269
  /**
270
- * One cache-key component row for the `entry_inputs` table — keyed by
270
+ * One cache-key component of an `entry_inputs` row (v34: one row per entry) — keyed by
271
271
  * the cache-entry HASH it belongs to, not a run. Written inside the
272
272
  * entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
273
273
  * cache HIT does not save, so it persists nothing (warm runs are free).
@@ -730,12 +730,16 @@ export interface CacheLayer {
730
730
  */
731
731
  hashFile(filePath: string): Promise<string>
732
732
  /**
733
- * Absolute path to the on-disk outputs artifact for a hash —
733
+ * Absolute path a file-backed artifact for a hash has —
734
734
  * `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
735
- * not the artifact exists. Exposed for telemetry / dashboards;
736
- * `restoreOutputs` is the canonical way to materialize the bytes.
735
+ * not the artifact exists: a small artifact lives in the index, not
736
+ * in a file (`artifactSize` is its size). Exposed for telemetry /
737
+ * dashboards; `restoreOutputs` is the canonical way to materialize
738
+ * the bytes.
737
739
  */
738
740
  outputsPath(hash: string): string
741
+ /** The stored artifact's compressed size, wherever it lives; undefined when none is stored. */
742
+ artifactSize?(hash: string): number | undefined
739
743
  prune(options: PruneOptions): Promise<PruneResult>
740
744
  close(): void
741
745
  }
@@ -529,6 +529,10 @@ export class LayeredCache implements CacheLayer {
529
529
  return this.local.outputsPath(hash)
530
530
  }
531
531
 
532
+ artifactSize(hash: string): number | undefined {
533
+ return this.local.artifactSize(hash)
534
+ }
535
+
532
536
  hashFile(filePath: string): Promise<string> {
533
537
  return this.local.hashFile(filePath)
534
538
  }
@@ -1,7 +1,8 @@
1
1
  // The index's tables, one concern: what a cache.db holds. `Cache` opens the
2
2
  // file, checks `SCHEMA_VERSION` and creates these; every statement against
3
3
  // them lives in the store that owns the table (file-hashes, config-evals,
4
- // output-index, history) or in cache.ts for `entries` and `entry_inputs`.
4
+ // output-index, history) or in cache.ts for `entries`, `entry_inputs` and
5
+ // `artifacts`.
5
6
 
6
7
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
8
 
@@ -255,22 +256,18 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
255
256
  CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
256
257
  CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
257
258
  CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
258
- -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
259
- -- component, keyed by the cache-ENTRY hash it belongs to (NOT a
260
- -- run id). Written inside the entry-save transaction — only on a
261
- -- miss/save, never on a hit — so a warm all-cache-hit run writes
262
- -- nothing here (the warm path does zero extra work). The
263
- -- why-did-this-re-run diff reads two entry hashes (this run's
264
- -- runs.hash and the previous run's) and anti-joins their rows in
265
- -- SQL over (kind,name,hash); a JSON blob would force an app-side
266
- -- parse + compare on every probe. ON DELETE CASCADE keeps these
267
- -- rows in sync with entries (a prune sweeps them automatically).
259
+ -- v22 (Tier 3): the input-fingerprint moat: each cache ENTRY's key
260
+ -- components (NOT a run's). Written inside the entry-save transaction,
261
+ -- only on a miss/save, so a warm all-cache-hit run writes nothing
262
+ -- here. v34: one row per entry, the components a JSON array of
263
+ -- [kind, name, hash] in (kind, name) code-unit order. A row per
264
+ -- component cost a b-tree insert each, and a task with 50 input
265
+ -- files spent ~0.5 ms a save on them; every reader loads the whole
266
+ -- set by entry hash and diffs it app-side anyway. ON DELETE CASCADE
267
+ -- keeps it in sync with entries (a prune sweeps it automatically).
268
268
  CREATE TABLE IF NOT EXISTS ${store}.entry_inputs (
269
- entry_hash TEXT NOT NULL,
270
- kind TEXT NOT NULL,
271
- name TEXT NOT NULL,
272
- hash TEXT NOT NULL,
273
- PRIMARY KEY (entry_hash, kind, name),
269
+ entry_hash TEXT PRIMARY KEY,
270
+ components TEXT NOT NULL,
274
271
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
275
272
  );
276
273
  -- v32: what belongs to the entries rather than to one workspace: the
@@ -280,9 +277,38 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
280
277
  key TEXT PRIMARY KEY,
281
278
  value TEXT NOT NULL
282
279
  );
280
+ ${artifactTables(store)}
283
281
  `)
284
282
  }
285
283
 
284
+ /**
285
+ * v33: an artifact of at most `INLINE_MAX` compressed bytes lives here, its
286
+ * exact `<hash>.tar.zst` bytes, written in the transaction that writes its
287
+ * rows. No foreign key and outside every drop a `SCHEMA_VERSION` reset makes:
288
+ * the artifact is the record and the entries only its inventory, so a reset
289
+ * drops the rows and keeps these, as it keeps the files. `artifacts_meta`
290
+ * holds the table's own layout version (`'layout'`), the one thing that may
291
+ * drop it. `at` is the last use a row-less sweep judges, as a file's mtime;
292
+ * it sits before `bytes` so reading it never walks a blob's overflow pages.
293
+ */
294
+ export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
295
+ db.exec(artifactTables(store))
296
+ }
297
+
298
+ // In the entry tables' exec, so an open runs no statement more for them.
299
+ function artifactTables(store: 'main' | 'store'): string {
300
+ return `
301
+ CREATE TABLE IF NOT EXISTS ${store}.artifacts (
302
+ hash TEXT PRIMARY KEY,
303
+ at INTEGER NOT NULL,
304
+ bytes BLOB NOT NULL
305
+ ) WITHOUT ROWID;
306
+ CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
307
+ key TEXT PRIMARY KEY,
308
+ value TEXT NOT NULL
309
+ );`
310
+ }
311
+
286
312
  /**
287
313
  * A `WHERE <column> …` test against many hashes, as one statement whatever
288
314
  * their number. A list of `?` is a new statement per length, compiled each
@@ -0,0 +1,21 @@
1
+ // The pages `vx docs` searches: core's reference, imported as text so the
2
+ // compiled binary carries them and an npm install reads them from the
3
+ // package (package.json `files`). Loaded only by `vx docs`.
4
+ import caching from '../../docs/caching.md' with { type: 'text' }
5
+ import cli from '../../docs/cli.md' with { type: 'text' }
6
+ import execution from '../../docs/execution.md' with { type: 'text' }
7
+ import features from '../../docs/features.md' with { type: 'text' }
8
+ import patterns from '../../docs/patterns.md' with { type: 'text' }
9
+ import schema from '../../docs/schema.md' with { type: 'text' }
10
+ import security from '../../docs/security.md' with { type: 'text' }
11
+
12
+ /** Page name (its file, no `.md`) → markdown. */
13
+ export const PAGES: Readonly<Record<string, string>> = {
14
+ cli,
15
+ schema,
16
+ caching,
17
+ execution,
18
+ patterns,
19
+ security,
20
+ features,
21
+ }
@@ -0,0 +1,5 @@
1
+ // A markdown page imported `with { type: 'text' }` is its text (docs-corpus.ts).
2
+ declare module '*.md' {
3
+ const text: string
4
+ export default text
5
+ }
@@ -0,0 +1,167 @@
1
+ // `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
2
+ // reference offline, for an agent that cannot reach the site: each page is
3
+ // cut at its headings, and the sections holding every query word print
4
+ // whole, best first.
5
+
6
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
+
8
+ const SITE = 'https://vznjs.github.io/vx/'
9
+ const DEFAULT_LIMIT = 3
10
+
11
+ interface DocsArgs {
12
+ terms: string[]
13
+ limit: number
14
+ json: boolean
15
+ error?: string
16
+ }
17
+
18
+ function parseDocsArgs(args: readonly string[]): DocsArgs {
19
+ const out: DocsArgs = { terms: [], limit: DEFAULT_LIMIT, json: false }
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i]!
22
+ if (a === '--format' || a.startsWith('--format=')) {
23
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'docs')
24
+ if (typeof fv === 'object') return { ...out, error: fv.error }
25
+ out.json = fv === 'json'
26
+ } else if (a === '--limit' || a.startsWith('--limit=')) {
27
+ const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
28
+ const n = Number(v)
29
+ if (!Number.isInteger(n) || n < 1) {
30
+ return {
31
+ ...out,
32
+ error: `--limit takes a whole number above 0 (got ${v ?? 'nothing'})${seeHelp('docs')}`,
33
+ }
34
+ }
35
+ out.limit = n
36
+ } else if (a.startsWith('-')) {
37
+ return { ...out, error: `${refusedWord(a)}: ${a}${flagHint('docs', a)}${seeHelp('docs')}` }
38
+ } else out.terms.push(...words(a))
39
+ }
40
+ return out
41
+ }
42
+
43
+ export interface DocsHit {
44
+ page: string
45
+ heading: string
46
+ url: string
47
+ text: string
48
+ }
49
+
50
+ function words(s: string): string[] {
51
+ return s.toLowerCase().match(/[a-z0-9][a-z0-9._-]*/g) ?? []
52
+ }
53
+
54
+ /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
55
+ function anchor(heading: string): string {
56
+ return heading
57
+ .toLowerCase()
58
+ .replace(/[^\p{L}\p{N} _-]/gu, '')
59
+ .replace(/ /g, '-')
60
+ }
61
+
62
+ /** A page's URL on the site; `features.md` is served as `all-features/`. */
63
+ function pageUrl(page: string): string {
64
+ return `${SITE}${page === 'features' ? 'all-features' : page}/`
65
+ }
66
+
67
+ interface Section {
68
+ page: string
69
+ heading: string
70
+ text: string
71
+ }
72
+
73
+ /** Cut a page at its headings (outside code fences); the text before the first is the page's own. */
74
+ function sections(page: string, md: string): Section[] {
75
+ const out: Section[] = []
76
+ let heading = ''
77
+ let lines: string[] = []
78
+ let fence = false
79
+ const flush = () => {
80
+ const text = lines.join('\n').trim()
81
+ if (text !== '') out.push({ page, heading, text })
82
+ }
83
+ for (const line of md.split('\n')) {
84
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence
85
+ const h = fence ? null : /^#{1,4} (.+)$/.exec(line)
86
+ if (h !== null) {
87
+ flush()
88
+ heading = h[1]!.replace(/`/g, '').trim()
89
+ lines = [line]
90
+ } else lines.push(line)
91
+ }
92
+ flush()
93
+ return out
94
+ }
95
+
96
+ function count(hay: string, term: string): number {
97
+ let n = 0
98
+ for (let i = hay.indexOf(term); i !== -1; i = hay.indexOf(term, i + term.length)) n++
99
+ return n
100
+ }
101
+
102
+ /** The sections holding every term, best first: a term in the heading outweighs any count in the text. */
103
+ export function searchDocs(
104
+ pages: Readonly<Record<string, string>>,
105
+ terms: readonly string[],
106
+ limit: number,
107
+ ): DocsHit[] {
108
+ const scored: { s: Section; score: number; order: number }[] = []
109
+ let order = 0
110
+ for (const [page, md] of Object.entries(pages)) {
111
+ for (const s of sections(page, md)) {
112
+ order++
113
+ const head = s.heading.toLowerCase()
114
+ const body = s.text.toLowerCase()
115
+ let score = 0
116
+ let all = true
117
+ for (const t of terms) {
118
+ const inBody = count(body, t)
119
+ if (inBody === 0) all = false
120
+ score += (head.includes(t) ? 10 : 0) + Math.min(inBody, 5)
121
+ }
122
+ if (all) scored.push({ s, score, order })
123
+ }
124
+ }
125
+ scored.sort((a, b) => b.score - a.score || a.order - b.order)
126
+ return scored.slice(0, limit).map(({ s }) => ({
127
+ page: s.page,
128
+ heading: s.heading,
129
+ url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
130
+ text: s.text,
131
+ }))
132
+ }
133
+
134
+ export async function docsCmd(args: readonly string[]): Promise<number> {
135
+ const parsed = parseDocsArgs(args)
136
+ if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
137
+ if (parsed.terms.length === 0) {
138
+ return refuse(
139
+ 'docs',
140
+ args,
141
+ `name what to look for, as in \`vx docs cache inputs\`${seeHelp('docs')}`,
142
+ 'VX_E_USAGE',
143
+ )
144
+ }
145
+ const { PAGES } = await import('./docs-corpus.js')
146
+ const hits = searchDocs(PAGES, parsed.terms, parsed.limit)
147
+ const query = parsed.terms.join(' ')
148
+ if (parsed.json) {
149
+ process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
150
+ return 0
151
+ }
152
+ if (hits.length === 0) {
153
+ process.stdout.write(
154
+ `vx docs: no section of ${Object.keys(PAGES).join(', ')} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
155
+ )
156
+ return 0
157
+ }
158
+ process.stdout.write(
159
+ hits
160
+ .map(
161
+ (h) =>
162
+ `── ${h.page}.md${h.heading === '' ? '' : ` § ${h.heading}`} · ${h.url}\n\n${h.text}\n`,
163
+ )
164
+ .join('\n'),
165
+ )
166
+ return 0
167
+ }
package/src/cli/help.ts CHANGED
@@ -108,13 +108,14 @@ export function helpText(pluginCommands: readonly string[] = []): string {
108
108
  ' vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]',
109
109
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
110
110
  ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
111
- ' vx lock [--check]',
112
- ' vx init [--dry] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
111
+ ' vx lock [--check] [--format pretty|json]',
112
+ ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
113
113
  ' vx upgrade [TAG]',
114
114
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
115
115
  ' vx info [--format pretty|json] [--cache-dir <path>]',
116
116
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
117
117
  ' vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--format pretty|json] [--cache-dir <path>]',
118
+ ' vx docs <query> [--limit N] [--format pretty|json]',
118
119
  ' vx completions bash|zsh|fish',
119
120
  ' vx help [VERB]',
120
121
  ' vx version',
@@ -241,6 +242,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
241
242
  ' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
242
243
  ' runs @vzn/vx-migrate, which asks native or keep.',
243
244
  ' --dry Print the generated files instead of writing them.',
245
+ ' --format json With --dry: the plan as one JSON document (schemas/init.json).',
244
246
  ' --force Overwrite existing vx.config.* files.',
245
247
  ' --mjs Write .mjs files (no type import) instead of .ts.',
246
248
  ' --native Turbo or Nx: write a vx.config.ts per package (no question).',
@@ -256,6 +258,13 @@ export function helpText(pluginCommands: readonly string[] = []): string {
256
258
  ' every flag of each. `eval "$(vx completions bash)"`,',
257
259
  ' or write it where the shell loads completions from.',
258
260
  '',
261
+ 'Docs:',
262
+ ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
263
+ ' patterns, security, features): the sections holding every',
264
+ ' word of the query print whole, best first, with their URL.',
265
+ ' --limit N How many sections (default 3).',
266
+ ' --format json {query, hits: [{page, heading, url, text}]}.',
267
+ '',
259
268
  'Config lock:',
260
269
  ' vx lock Evaluate every vx.config in the current environment and',
261
270
  ' freeze the resolved objects into vx-lock.json. Plain runs',
@@ -264,7 +273,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
264
273
  ' vx lock --check Audit the lock: file-changed hash report PLUS a full',
265
274
  ' re-evaluation compared against the frozen objects. Exits 1',
266
275
  ' on drift (e.g. configs that read env vars). Run it before',
267
- ' `vx run --frozen` in CI.',
276
+ ' `vx run --frozen` in CI. With --format json it prints',
277
+ ' {upToDate, audited, notAudited, problems}.',
268
278
  '',
269
279
  ...(pluginCommands.length > 0 ? ['Plugin commands:', ...pluginCommands, ''] : []),
270
280
  ].join('\n')
package/src/cli/index.ts CHANGED
@@ -109,6 +109,8 @@ export async function run(argv: readonly string[]): Promise<number> {
109
109
  return await (await import('./cache.js')).cacheCmd(rest)
110
110
  case 'lock':
111
111
  return await (await import('./lock.js')).lockCmd(rest)
112
+ case 'docs':
113
+ return await (await import('./docs.js')).docsCmd(rest)
112
114
  case 'init':
113
115
  return await (await import('./init.js')).initCmd(rest)
114
116
  case 'upgrade':
package/src/cli/init.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `vx init [--dry] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
1
+ // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
2
2
  // one vx.config.ts per package from its package.json scripts, and the
3
3
  // workspace file every run needs. In a Turbo or Nx repo it hands over to
4
4
  // `@vzn/vx-migrate` (`migrate`), which asks a terminal whether to write
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { mkdir, unlink } from 'node:fs/promises'
12
12
  import path from 'node:path'
13
- import { flagHint, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
15
15
  import { isCompiledBinary } from './upgrade.js'
16
16
  import { VERSION } from '../version.js'
@@ -34,6 +34,8 @@ interface InitArgs {
34
34
  plugin?: string
35
35
  /** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
36
36
  mode?: 'native' | 'keep'
37
+ /** `--format json` (with `--dry`): the plan as one JSON document. */
38
+ json?: boolean
37
39
  error?: string
38
40
  }
39
41
 
@@ -51,6 +53,10 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
51
53
  }
52
54
  }
53
55
  out.plugin = seam
56
+ } else if (a === '--format' || a.startsWith('--format=')) {
57
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'init')
58
+ if (typeof fv === 'object') return { ...out, error: fv.error }
59
+ out.json = fv === 'json'
54
60
  } else if (a === '--dry') out.dry = true
55
61
  else if (a === '--force') out.force = true
56
62
  else if (a === '--mjs') out.mjs = true
@@ -81,9 +87,15 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
81
87
 
82
88
  export async function initCmd(args: readonly string[]): Promise<number> {
83
89
  const parsed = parseInitArgs(args)
84
- if (parsed.error) {
85
- process.stderr.write(`vx init: ${parsed.error}\n`)
86
- return 1
90
+ if (parsed.error) return refuse('init', args, parsed.error, 'VX_E_USAGE')
91
+ // In the verb, not the parser: completion asks the parser one flag at a time.
92
+ if (parsed.json === true && (!parsed.dry || parsed.plugin !== undefined)) {
93
+ return refuse(
94
+ 'init',
95
+ args,
96
+ `--format json prints the workspace plan, so it needs --dry and no --plugin${seeHelp('init')}`,
97
+ 'VX_E_USAGE',
98
+ )
87
99
  }
88
100
  if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
89
101
  const reads: LoadReads = new Map()
@@ -163,6 +175,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
163
175
  notes: [...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
164
176
  unmapped: unmapped.length > 0,
165
177
  dry: parsed.dry,
178
+ json: parsed.json === true,
166
179
  force: parsed.force,
167
180
  init: true,
168
181
  format: parsed.mjs ? 'mjs' : 'ts',
@@ -211,6 +224,7 @@ async function migrate(root: string, args: InitArgs): Promise<number> {
211
224
  args.force && '--force',
212
225
  args.mjs && '--mjs',
213
226
  args.mode === 'native' && '--native',
227
+ args.json === true && '--format=json',
214
228
  ].filter((f): f is string => typeof f === 'string')
215
229
  return Bun.spawn(migrateCommand(root, flags), {
216
230
  cwd: root,
@@ -257,7 +271,7 @@ async function adopt(
257
271
  )
258
272
  }
259
273
  } else if (args.dry) {
260
- process.stdout.write(`── ${name} ──\n${text}\n`)
274
+ if (args.json !== true) process.stdout.write(`── ${name} ──\n${text}\n`)
261
275
  } else {
262
276
  if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
263
277
  await Bun.write(path.join(root, name), text)
@@ -278,6 +292,21 @@ async function adopt(
278
292
  : kept
279
293
  ? ''
280
294
  : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
295
+ if (args.json === true) {
296
+ process.stdout.write(
297
+ `${JSON.stringify({
298
+ dry: true,
299
+ source,
300
+ files: kept ? [] : [{ path: name, contents: text }],
301
+ kept: kept ? [existing] : [],
302
+ replaced: !kept && existing !== undefined && existing !== name ? [existing] : [],
303
+ todos: [],
304
+ notes: cacheLine === '' ? [] : [cacheLine.trimEnd()],
305
+ next: adoptionNext(root, runner, source),
306
+ })}\n`,
307
+ )
308
+ return 0
309
+ }
281
310
  process.stdout.write(
282
311
  `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
283
312
  `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
package/src/cli/lock.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
- import { flagHint, refusedWord, seeHelp } from './help.js'
6
+ import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
7
  import { relPosix, secretMask, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
@@ -22,16 +22,22 @@ import { discoverCliProjects } from './workspace-config.js'
22
22
 
23
23
  interface LockArgs {
24
24
  check: boolean
25
+ format: 'pretty' | 'json'
25
26
  error?: string
26
27
  }
27
28
 
28
29
  export function parseLockArgs(args: readonly string[]): LockArgs {
29
- const out: LockArgs = { check: false }
30
- for (const a of args) {
30
+ const out: LockArgs = { check: false, format: 'pretty' }
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i]!
31
33
  if (a === '--check') out.check = true
32
- else
34
+ else if (a === '--format' || a.startsWith('--format=')) {
35
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'lock')
36
+ if (typeof fv === 'object') return { ...out, ...fv }
37
+ out.format = fv
38
+ } else
33
39
  return {
34
- check: false,
40
+ ...out,
35
41
  error: `${refusedWord(a)}: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
36
42
  }
37
43
  }
@@ -42,10 +48,15 @@ type ConfiguredMeta = ProjectMeta & { configPath: string }
42
48
 
43
49
  export async function lockCmd(args: readonly string[]): Promise<number> {
44
50
  const parsed = parseLockArgs(args)
45
- if (parsed.error) {
46
- process.stderr.write(`vx lock: ${parsed.error}\n`)
47
- return 1
48
- }
51
+ if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
52
+ // Writing the lock prints one line; only the audit has an answer to shape.
53
+ if (parsed.format === 'json' && !parsed.check)
54
+ return refuse(
55
+ 'lock',
56
+ args,
57
+ `--format json answers --check only${seeHelp('lock')}`,
58
+ 'VX_E_USAGE',
59
+ )
49
60
  const reads: LoadReads = new Map()
50
61
  const root = await findWorkspaceRoot(process.cwd(), reads)
51
62
  const workspace = await loadWorkspace(root, reads)
@@ -62,7 +73,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
62
73
  // count above leaves out — `locked 0 project configs` on a turbo() repo
63
74
  // read like an audit of something (2026-09-16).
64
75
  const bare = all.length - metas.length
65
- if (parsed.check) return await checkLock(root, metas, bare)
76
+ if (parsed.check) return await checkLock(root, metas, bare, parsed.format === 'json')
66
77
  return await writeLock(root, metas, bare)
67
78
  }
68
79
 
@@ -154,13 +165,29 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
154
165
  * catches eval-time env-var drift that file hashes cannot see —
155
166
  * file bytes unchanged, resolved value changed.
156
167
  */
157
- async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
168
+ async function checkLock(
169
+ root: string,
170
+ metas: ConfiguredMeta[],
171
+ bare: number,
172
+ json: boolean,
173
+ ): Promise<number> {
174
+ const answer = (problems: string[]): number => {
175
+ if (json) {
176
+ const out = {
177
+ upToDate: problems.length === 0,
178
+ audited: metas.length,
179
+ notAudited: bare,
180
+ problems,
181
+ }
182
+ process.stdout.write(`${JSON.stringify(out)}\n`)
183
+ }
184
+ return problems.length === 0 ? 0 : 1
185
+ }
158
186
  const lock = await readLockfile(root)
159
187
  if (!lock) {
160
- process.stderr.write(
161
- `vx lock --check: no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first\n`,
162
- )
163
- return 1
188
+ const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
189
+ if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
190
+ return answer([problem])
164
191
  }
165
192
  const results = await Promise.all(
166
193
  metas.map(async (m): Promise<string | null> => {
@@ -200,6 +227,7 @@ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): P
200
227
  )
201
228
  }
202
229
  }
230
+ if (json) return answer(failures)
203
231
  if (failures.length > 0) {
204
232
  for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
205
233
  return 1