@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.
- package/README.md +14 -10
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/skills/vx/SKILL.md +6 -2
- package/src/cache/cache.ts +500 -121
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +35 -6
- package/src/cli/lock.ts +43 -15
- package/src/cli/upgrade.ts +18 -6
- package/src/graph/task-graph.ts +3 -3
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
|
@@ -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
|
}
|
package/src/cache/inputs.ts
CHANGED
|
@@ -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[] = ['**/*']
|
package/src/cache/layer.ts
CHANGED
|
@@ -267,7 +267,7 @@ export interface InvocationRecord {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/**
|
|
270
|
-
* One cache-key component
|
|
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
|
|
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
|
|
736
|
-
* `
|
|
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
|
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
259
|
-
--
|
|
260
|
-
--
|
|
261
|
-
--
|
|
262
|
-
--
|
|
263
|
-
--
|
|
264
|
-
--
|
|
265
|
-
--
|
|
266
|
-
--
|
|
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
|
|
270
|
-
|
|
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
|
+
}
|
package/src/cli/docs.ts
ADDED
|
@@ -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
|
-
|
|
86
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
161
|
-
|
|
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
|