@vzn/vx 0.0.641 → 0.0.643
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/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
|
@@ -1,20 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// A branch's cache scope (owner, 2026-10-17): the local store never knows
|
|
2
|
+
// one, so every branch shares one local pool under the task keys. Scopes
|
|
3
|
+
// decide only which remote entry a task asks for: a task the branch changed
|
|
4
|
+
// reads the scope's key, every other task the default key, and a scoped run
|
|
5
|
+
// writes every remote under the scope's key. An artifact is packed under its
|
|
6
|
+
// task key wherever it is stored, so a remote entry ingests under that key.
|
|
5
7
|
|
|
6
8
|
import { xxh3hex } from '../util/index.js'
|
|
7
|
-
import type {
|
|
8
|
-
CacheEntry,
|
|
9
|
-
CacheGetContext,
|
|
10
|
-
CacheLayer,
|
|
11
|
-
DeclaredOutputs,
|
|
12
|
-
IngestMeta,
|
|
13
|
-
InvocationRecord,
|
|
14
|
-
OutputDirRow,
|
|
15
|
-
RunRecord,
|
|
16
|
-
SaveArgs,
|
|
17
|
-
} from './layer.js'
|
|
9
|
+
import type { RemoteCacheLayer, RemoteKey } from './layered-cache.js'
|
|
18
10
|
|
|
19
11
|
/** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
|
|
20
12
|
export function scopedKey(scope: string, hash: string): string {
|
|
@@ -22,229 +14,74 @@ export function scopedKey(scope: string, hash: string): string {
|
|
|
22
14
|
}
|
|
23
15
|
|
|
24
16
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the run's history all reach the entry that answered. An artifact records
|
|
29
|
-
* the key it was packed under and ingest refuses another, so a derived-key
|
|
30
|
-
* collision degrades to a miss. A default artifact a remote ingest refuses is
|
|
31
|
-
* a miss there, and the scope's key is asked next (Security F6).
|
|
17
|
+
* Which remote key each task asks for, in a run with a scope. The routes
|
|
18
|
+
* come from the branch's diff, which only a remote call awaits: a run the
|
|
19
|
+
* local cache answers never waits on git.
|
|
32
20
|
*/
|
|
33
|
-
export class
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
readonly
|
|
39
|
-
|
|
40
|
-
readonly
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
21
|
+
export class BranchScope {
|
|
22
|
+
/** Finds the tasks that read the scope, at the first remote call; null: every task does. */
|
|
23
|
+
private route: (() => Promise<ReadonlySet<string>>) | null = null
|
|
24
|
+
private routes: Promise<ReadonlySet<string>> | null = null
|
|
25
|
+
/** Task key → its task. */
|
|
26
|
+
private readonly taskOf = new Map<string, string>()
|
|
27
|
+
|
|
28
|
+
constructor(readonly scope: string) {}
|
|
29
|
+
|
|
30
|
+
/** Only the tasks `ids` finds read the scope; set before any remote call. */
|
|
31
|
+
routeTasks(ids: () => Promise<ReadonlySet<string>>): void {
|
|
32
|
+
this.route = ids
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
noteKey(taskId: string, hash: string): void {
|
|
36
|
+
this.taskOf.set(hash, taskId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The remote key `hash` is read under, and the scope it is read in (none: the default key). */
|
|
40
|
+
private async readKey(hash: string): Promise<{ key: string; at: RemoteKey }> {
|
|
41
|
+
const id = this.taskOf.get(hash)
|
|
42
|
+
if (this.route !== null) this.routes ??= this.route()
|
|
43
|
+
const tasks = this.routes === null ? null : await this.routes
|
|
44
|
+
return tasks !== null && id !== undefined && !tasks.has(id)
|
|
45
|
+
? { key: hash, at: {} }
|
|
46
|
+
: { key: scopedKey(this.scope, hash), at: { scope: this.scope } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `inner` as this run asks it: each task key under its route's key; every
|
|
51
|
+
* write under the scope's. Each call also names its scope, for a backend
|
|
52
|
+
* that keeps one store per scope; no wire vx ships sends it.
|
|
53
|
+
*/
|
|
54
|
+
remote(inner: RemoteCacheLayer): RemoteCacheLayer {
|
|
55
|
+
const read = (h: string) => this.readKey(h)
|
|
56
|
+
const at: RemoteKey = { scope: this.scope }
|
|
57
|
+
const hasMany = inner.hasMany
|
|
58
|
+
return {
|
|
59
|
+
...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
|
|
60
|
+
...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
|
|
61
|
+
has: async (h) => {
|
|
62
|
+
const r = await read(h)
|
|
63
|
+
return inner.has(r.key, r.at)
|
|
64
|
+
},
|
|
65
|
+
...(hasMany !== undefined
|
|
66
|
+
? {
|
|
67
|
+
hasMany: async (hashes) => {
|
|
68
|
+
const reads = await Promise.all(hashes.map(read))
|
|
69
|
+
const keys = reads.map((r) => r.key)
|
|
70
|
+
const found = await hasMany.call(
|
|
71
|
+
inner,
|
|
72
|
+
keys,
|
|
73
|
+
reads.map((r) => r.at),
|
|
74
|
+
)
|
|
75
|
+
if (!(found instanceof Set)) return found
|
|
76
|
+
return new Set(hashes.filter((_, i) => found.has(keys[i]!)))
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
get: async (h) => {
|
|
81
|
+
const r = await read(h)
|
|
82
|
+
return inner.get(r.key, r.at)
|
|
83
|
+
},
|
|
84
|
+
put: (h, body, meta) => inner.put(scopedKey(this.scope, h), body, { ...meta, ...at }),
|
|
82
85
|
}
|
|
83
|
-
return out
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** A task key the store holds under the default key or the scope's. */
|
|
87
|
-
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
88
|
-
const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
|
|
89
|
-
if (held === undefined) return new Set()
|
|
90
|
-
return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
94
|
-
const scoped = hashes.map((h) => this.own(h))
|
|
95
|
-
const [plain, own] = await Promise.all([
|
|
96
|
-
this.inner.remoteHasMany!(hashes),
|
|
97
|
-
this.inner.remoteHasMany!(scoped),
|
|
98
|
-
])
|
|
99
|
-
if (plain == null || own == null) return null
|
|
100
|
-
const found = new Set(plain)
|
|
101
|
-
const scopeOnly: string[] = []
|
|
102
|
-
for (let i = 0; i < hashes.length; i++) {
|
|
103
|
-
if (plain.has(hashes[i]!) || !own.has(scoped[i]!)) continue
|
|
104
|
-
found.add(hashes[i]!)
|
|
105
|
-
scopeOnly.push(hashes[i]!)
|
|
106
|
-
}
|
|
107
|
-
// Their default key is known absent: its GET is skipped.
|
|
108
|
-
this.inner.markRemoteAbsent?.(scopeOnly)
|
|
109
|
-
return found
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
markRemoteAbsent(hashes: Iterable<string>): void {
|
|
113
|
-
const all: string[] = []
|
|
114
|
-
for (const h of hashes) all.push(h, this.own(h))
|
|
115
|
-
this.inner.markRemoteAbsent?.(all)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
key(input: Parameters<CacheLayer['key']>[0]): Promise<string> {
|
|
119
|
-
return this.inner.key(input)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
123
|
-
// The scope's key first when only it is local: the default key's
|
|
124
|
-
// remote GET would cost a round-trip for the same inputs.
|
|
125
|
-
const own = this.own(hash)
|
|
126
|
-
const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
|
|
127
|
-
? [own, hash]
|
|
128
|
-
: [hash, own]
|
|
129
|
-
for (const key of keys) {
|
|
130
|
-
const entry = await this.inner.get(key, ctx)
|
|
131
|
-
if (entry === null) continue
|
|
132
|
-
this.served.set(hash, key)
|
|
133
|
-
return key === hash ? entry : { ...entry, hash }
|
|
134
|
-
}
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private async scopedGetMany(
|
|
139
|
-
hashes: readonly string[],
|
|
140
|
-
ctx?: (hash: string) => CacheGetContext,
|
|
141
|
-
): Promise<Map<string, CacheEntry>> {
|
|
142
|
-
const out = await this.inner.getMany!(hashes, ctx)
|
|
143
|
-
for (const h of out.keys()) this.served.set(h, h)
|
|
144
|
-
const rest = hashes.filter((h) => !out.has(h))
|
|
145
|
-
if (rest.length === 0) return out
|
|
146
|
-
const byOwn = new Map(rest.map((h) => [this.own(h), h]))
|
|
147
|
-
const scoped = await this.inner.getMany!(
|
|
148
|
-
[...byOwn.keys()],
|
|
149
|
-
ctx === undefined ? undefined : (key) => ctx(byOwn.get(key)!),
|
|
150
|
-
)
|
|
151
|
-
for (const [key, entry] of scoped) {
|
|
152
|
-
const hash = byOwn.get(key)!
|
|
153
|
-
this.served.set(hash, key)
|
|
154
|
-
out.set(hash, { ...entry, hash })
|
|
155
|
-
}
|
|
156
|
-
return out
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
160
|
-
return (await this.inner.has(hash)) ?? (await this.inner.has(this.own(hash)))
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
164
|
-
return (await this.inner.prefetch(hash, ctx)) || this.inner.prefetch(this.own(hash), ctx)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
loadOutputFilesBatch(hashes: readonly string[]): ReturnType<CacheLayer['loadOutputFilesBatch']> {
|
|
168
|
-
return this.batch(hashes, (keys) => this.inner.loadOutputFilesBatch(keys))
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
isOutputsCurrent(
|
|
172
|
-
...args: Parameters<CacheLayer['isOutputsCurrent']>
|
|
173
|
-
): ReturnType<CacheLayer['isOutputsCurrent']> {
|
|
174
|
-
return this.inner.isOutputsCurrent(...args)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async recordOutputDirs(
|
|
178
|
-
hash: string,
|
|
179
|
-
projectDir: string,
|
|
180
|
-
prefixes: readonly string[],
|
|
181
|
-
holds?: (files: readonly string[]) => boolean,
|
|
182
|
-
): Promise<void> {
|
|
183
|
-
await this.inner.recordOutputDirs?.(this.keyOf(hash), projectDir, prefixes, holds)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
187
|
-
this.inner.recordOutputStamps?.(this.keyOf(hash), projectDir, workspaceRoot)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
191
|
-
return this.batch(hashes, (keys) => this.inner.loadOutputDirsBatch?.(keys) ?? new Map())
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
195
|
-
return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
restoreOutputs(
|
|
199
|
-
hash: string,
|
|
200
|
-
projectDir: string,
|
|
201
|
-
workspaceRoot?: string,
|
|
202
|
-
outputs?: DeclaredOutputs,
|
|
203
|
-
): Promise<void> {
|
|
204
|
-
return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
save(args: SaveArgs): Promise<void> {
|
|
208
|
-
const key = this.own(args.hash)
|
|
209
|
-
this.served.set(args.hash, key)
|
|
210
|
-
return this.inner.save({ ...args, hash: key })
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
214
|
-
const key = this.own(hash)
|
|
215
|
-
this.served.set(hash, key)
|
|
216
|
-
return this.inner.ingest(key, body, meta)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** History names the key that answered, so `vx why` reads that entry's components. */
|
|
220
|
-
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
221
|
-
this.inner.recordRunBundle({
|
|
222
|
-
...bundle,
|
|
223
|
-
runs: bundle.runs.map((r) => (r.hash === undefined ? r : { ...r, hash: this.keyOf(r.hash) })),
|
|
224
|
-
})
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
stats(...args: Parameters<CacheLayer['stats']>): ReturnType<CacheLayer['stats']> {
|
|
228
|
-
return this.inner.stats(...args)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
hashFile(filePath: string): Promise<string> {
|
|
232
|
-
return this.inner.hashFile(filePath)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
outputsPath(hash: string): string {
|
|
236
|
-
return this.inner.outputsPath(this.keyOf(hash))
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
artifactSize(hash: string): number | undefined {
|
|
240
|
-
return this.inner.artifactSize?.(this.keyOf(hash))
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
prune(...args: Parameters<CacheLayer['prune']>): ReturnType<CacheLayer['prune']> {
|
|
244
|
-
return this.inner.prune(...args)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
close(): void {
|
|
248
|
-
this.inner.close()
|
|
249
86
|
}
|
|
250
87
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The process umask, read when a restore starts: Bun reads it by setting 0
|
|
2
|
+
// and putting it back in one synchronous call (project-loader.ts
|
|
3
|
+
// `currentUmask`), so no spawn on this thread sees the 0, and a run
|
|
4
|
+
// evaluates no config in a worker. Through globalThis: the playground's
|
|
5
|
+
// shim has no umask.
|
|
6
|
+
const proc = globalThis.process as { umask?: () => number } | undefined
|
|
7
|
+
let umask = 0
|
|
8
|
+
|
|
9
|
+
/** Read the umask again: a restore's modes follow the process's as it is now. */
|
|
10
|
+
export function readUmask(): void {
|
|
11
|
+
if (typeof proc?.umask === 'function') umask = proc.umask()
|
|
12
|
+
}
|
|
13
|
+
readUmask()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The permission bits a restore gives a stored `mode`: minus the umask, as
|
|
17
|
+
* tar gives a non-root user and Turbo does. A hit under `umask 077` gave
|
|
18
|
+
* back 0644 / 0755 where the build left 0600 / 0700, readable by every
|
|
19
|
+
* user on the machine (Growth's parity run, 2026-10-16).
|
|
20
|
+
*/
|
|
21
|
+
export function restoredMode(mode: number): number {
|
|
22
|
+
return mode & 0o777 & ~umask
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A file on disk holds the stored `mode`: as saved, or as a restore here leaves it. */
|
|
26
|
+
export function modeMatches(disk: number, stored: number): boolean {
|
|
27
|
+
const d = disk & 0o777
|
|
28
|
+
return d === (stored & 0o777) || d === restoredMode(stored)
|
|
29
|
+
}
|
package/src/cli/docs.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
|
|
2
|
-
// reference
|
|
2
|
+
// reference as this version wrote it: packages/vx/docs at its git tag on
|
|
3
|
+
// GitHub, read online (M, 2026-10-17: nothing is embedded). Each page is
|
|
3
4
|
// cut at its headings, and the sections holding every query word print
|
|
4
5
|
// whole, best first.
|
|
5
6
|
|
|
7
|
+
import { VERSION } from '../version.js'
|
|
6
8
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
9
|
|
|
8
10
|
const SITE = 'https://vznjs.github.io/vx/'
|
|
9
11
|
const DEFAULT_LIMIT = 3
|
|
12
|
+
const REPO = 'vznjs/vx'
|
|
13
|
+
const DIR = 'packages/vx/docs'
|
|
10
14
|
|
|
11
15
|
interface DocsArgs {
|
|
12
16
|
terms: string[]
|
|
@@ -59,9 +63,55 @@ function anchor(heading: string): string {
|
|
|
59
63
|
.replace(/ /g, '-')
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** A page's URL on the site
|
|
66
|
+
/** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
|
|
63
67
|
function pageUrl(page: string): string {
|
|
64
|
-
|
|
68
|
+
const served: Record<string, string> = {
|
|
69
|
+
README: 'overview',
|
|
70
|
+
features: 'all-features',
|
|
71
|
+
'turbo-nx-support': 'compare/turbo-nx-support',
|
|
72
|
+
}
|
|
73
|
+
return `${SITE}${served[page] ?? page.toLowerCase()}/`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The git ref this vx's docs are at: its release tag; a source checkout (0.0.0) reads main. */
|
|
77
|
+
export function docsRef(version: string = VERSION): string {
|
|
78
|
+
return version === '0.0.0' ? 'main' : `v${version}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every page of packages/vx/docs at `ref` (its own `.md` files, the
|
|
83
|
+
* maintainers' STATUS.md aside), page name → markdown. One listing, then
|
|
84
|
+
* the pages at once. Throws with what failed; a token in GITHUB_TOKEN or
|
|
85
|
+
* GH_TOKEN lifts GitHub's anonymous listing limit.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadPages(
|
|
88
|
+
ref: string,
|
|
89
|
+
get: (url: string, init?: RequestInit) => Promise<Response> = fetch,
|
|
90
|
+
env: Record<string, string | undefined> = process.env,
|
|
91
|
+
): Promise<Record<string, string>> {
|
|
92
|
+
const token = env['GITHUB_TOKEN'] ?? env['GH_TOKEN']
|
|
93
|
+
const listing = await get(
|
|
94
|
+
`https://api.github.com/repos/${REPO}/contents/${DIR}?ref=${encodeURIComponent(ref)}`,
|
|
95
|
+
{
|
|
96
|
+
headers: {
|
|
97
|
+
accept: 'application/vnd.github+json',
|
|
98
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
if (!listing.ok) throw new Error(`GitHub answered ${listing.status} listing ${DIR} at ${ref}`)
|
|
103
|
+
const names = ((await listing.json()) as Array<{ name: string; type: string }>)
|
|
104
|
+
.filter((e) => e.type === 'file' && e.name.endsWith('.md') && e.name !== 'STATUS.md')
|
|
105
|
+
.map((e) => e.name)
|
|
106
|
+
const texts = await Promise.all(
|
|
107
|
+
names.map(async (name) => {
|
|
108
|
+
const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${DIR}/${name}`
|
|
109
|
+
const r = await get(url)
|
|
110
|
+
if (!r.ok) throw new Error(`GitHub answered ${r.status} for ${url}`)
|
|
111
|
+
return r.text()
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
return Object.fromEntries(names.map((n, i) => [n.slice(0, -3), texts[i]!]))
|
|
65
115
|
}
|
|
66
116
|
|
|
67
117
|
interface Section {
|
|
@@ -137,7 +187,10 @@ export function searchDocs(
|
|
|
137
187
|
}))
|
|
138
188
|
}
|
|
139
189
|
|
|
140
|
-
export async function docsCmd(
|
|
190
|
+
export async function docsCmd(
|
|
191
|
+
args: readonly string[],
|
|
192
|
+
load: (ref: string) => Promise<Record<string, string>> = loadPages,
|
|
193
|
+
): Promise<number> {
|
|
141
194
|
const parsed = parseDocsArgs(args)
|
|
142
195
|
if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
|
|
143
196
|
if (parsed.terms.length === 0) {
|
|
@@ -148,8 +201,19 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
|
|
|
148
201
|
'VX_E_USAGE',
|
|
149
202
|
)
|
|
150
203
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
204
|
+
const ref = docsRef()
|
|
205
|
+
let pages: Record<string, string>
|
|
206
|
+
try {
|
|
207
|
+
pages = await load(ref)
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return refuse(
|
|
210
|
+
'docs',
|
|
211
|
+
args,
|
|
212
|
+
`cannot read the docs at ${ref} from GitHub (${err instanceof Error ? err.message : String(err)}); they are on the site, ${SITE}`,
|
|
213
|
+
'VX_E_NETWORK',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
const hits = searchDocs(pages, parsed.terms, parsed.limit)
|
|
153
217
|
const query = parsed.terms.join(' ')
|
|
154
218
|
if (parsed.json) {
|
|
155
219
|
process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
|
|
@@ -157,7 +221,7 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
|
|
|
157
221
|
}
|
|
158
222
|
if (hits.length === 0) {
|
|
159
223
|
process.stdout.write(
|
|
160
|
-
`vx docs: no section of ${
|
|
224
|
+
`vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
|
|
161
225
|
)
|
|
162
226
|
return 0
|
|
163
227
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -134,8 +134,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
134
134
|
' foo, @scope/*, ./packages/foo, foo..., ...foo,',
|
|
135
135
|
' foo^..., !foo, [<since>]',
|
|
136
136
|
' --affected[=<base>] Projects changed since <base>, and dependents',
|
|
137
|
-
' (--filter "...[<base>]"); default
|
|
138
|
-
'
|
|
137
|
+
' (--filter "...[<base>]"); default the merge base',
|
|
138
|
+
' with origin/<baseBranch>; none on that branch.',
|
|
139
139
|
" pkg#task Run a specific project's task directly.",
|
|
140
140
|
'',
|
|
141
141
|
'Execution (for run):',
|
|
@@ -159,8 +159,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
159
159
|
' flags; unnamed layers keep theirs. Examples:',
|
|
160
160
|
' local:rw,remote:r (remote read-only), remote:',
|
|
161
161
|
' (remote off), local:r (local read-only).',
|
|
162
|
-
" --cache-scope <name> This run's cache.scope (pr-123): write there,",
|
|
163
|
-
' read the default keys first.',
|
|
164
162
|
' --cache-dir <path> Cache directory (cwd-relative); default',
|
|
165
163
|
' .vx/cache, entries in ~/.vx/<id>/cache.',
|
|
166
164
|
' --cache-download <mode> all (default) | toplevel | none:',
|
|
@@ -287,10 +285,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
287
285
|
' or write it where the shell loads completions from.',
|
|
288
286
|
'',
|
|
289
287
|
'Docs:',
|
|
290
|
-
|
|
291
|
-
'
|
|
292
|
-
'
|
|
293
|
-
'
|
|
288
|
+
" vx docs <query> Search the reference at this version's tag on GitHub (online):",
|
|
289
|
+
' the sections holding every word of the query print whole,',
|
|
290
|
+
' best first, with their URL; an error code',
|
|
291
|
+
' (vx docs VX_E_CYCLE) prints its fix alone.',
|
|
294
292
|
' --limit N How many sections (default 3).',
|
|
295
293
|
' --format json {query, hits: [{page, heading, url, text}]}.',
|
|
296
294
|
'',
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -148,7 +148,10 @@ export interface PlanTaskJson {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
|
|
151
|
-
export function formatPlanJson(
|
|
151
|
+
export function formatPlanJson(
|
|
152
|
+
plan: Pick<RunPlan, 'tasks' | 'predicted' | 'downloadDowngrades'>,
|
|
153
|
+
affectedBase?: string,
|
|
154
|
+
): string {
|
|
152
155
|
return (
|
|
153
156
|
JSON.stringify(
|
|
154
157
|
{
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
|
|
11
11
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
|
-
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
13
|
-
import {
|
|
12
|
+
import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
13
|
+
import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
|
|
14
14
|
import { machineParallelism, maskedLine } from '../util/index.js'
|
|
15
15
|
import { resolveSetting } from '../settings.js'
|
|
16
16
|
|
|
@@ -49,6 +49,7 @@ async function workspacePlugins(cwd: string): Promise<
|
|
|
49
49
|
workspaceConfig: WorkspaceConfig | null
|
|
50
50
|
concurrency: number
|
|
51
51
|
plugins: readonly VxPlugin[]
|
|
52
|
+
branch: BranchFacts
|
|
52
53
|
}
|
|
53
54
|
| UnresolvedPluginCommand
|
|
54
55
|
| null
|
|
@@ -76,6 +77,7 @@ async function workspacePlugins(cwd: string): Promise<
|
|
|
76
77
|
(resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
|
|
77
78
|
machineParallelism(),
|
|
78
79
|
plugins: ws.plugins,
|
|
80
|
+
branch: ws.branch,
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -136,13 +138,16 @@ async function verbCache(
|
|
|
136
138
|
ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
|
|
137
139
|
plugin: string,
|
|
138
140
|
): Promise<PluginCache> {
|
|
139
|
-
const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
|
|
141
|
+
const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
|
|
140
142
|
import('../cache/index.js'),
|
|
141
143
|
import('../orchestrator/index.js'),
|
|
142
144
|
])
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
const scope = resolveCacheScope(
|
|
146
|
+
ws.workspaceRoot,
|
|
147
|
+
ws.branch.scope,
|
|
148
|
+
ws.plugins.some((p) => p.cache !== undefined),
|
|
149
|
+
configuredBaseBranch(ws.workspaceConfig),
|
|
150
|
+
)
|
|
146
151
|
const localCache = new Cache(
|
|
147
152
|
ws.cacheDir,
|
|
148
153
|
{ read: true, write: false },
|
package/src/cli/run.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isatty } from 'node:tty'
|
|
2
|
-
import {
|
|
2
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
3
3
|
import { translateForeign } from './foreign-flags.js'
|
|
4
4
|
import { flagHint, refuse, seeHelp } from './help.js'
|
|
5
5
|
import {
|
|
@@ -60,8 +60,6 @@ export interface RunArgs {
|
|
|
60
60
|
remoteRequested?: boolean
|
|
61
61
|
/** `--cache-dir <path>`: override the cache directory (cwd-relative). */
|
|
62
62
|
cacheDir: string | undefined
|
|
63
|
-
/** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
|
|
64
|
-
cacheScope?: string
|
|
65
63
|
/** `vx watch --poll` (`watch.poll`). */
|
|
66
64
|
poll?: true
|
|
67
65
|
forwardArgs: string[]
|
|
@@ -90,9 +88,7 @@ function flagOf(a: string | undefined, name: string): string | undefined {
|
|
|
90
88
|
// The settings parsed by their kind alone; the cache flags, `--continue`
|
|
91
89
|
// and `--cache-dir` keep their own branches above.
|
|
92
90
|
const GENERIC_FLAGS = SETTINGS.filter(
|
|
93
|
-
(s) =>
|
|
94
|
-
s.flag?.verb === 'run' &&
|
|
95
|
-
!['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
|
|
91
|
+
(s) => s.flag?.verb === 'run' && !['run.continue', 'cache.dir', 'cache.policy'].includes(s.path),
|
|
96
92
|
)
|
|
97
93
|
|
|
98
94
|
/** The setting a flag names. */
|
|
@@ -204,11 +200,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
204
200
|
}
|
|
205
201
|
}
|
|
206
202
|
out.cacheDir = v
|
|
207
|
-
} else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
|
|
208
|
-
const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
|
|
209
|
-
if (v === undefined || !isCacheScope(v))
|
|
210
|
-
return { ...out, error: cacheScopeError('--cache-scope') }
|
|
211
|
-
out.cacheScope = v
|
|
212
203
|
} else if (flagOf(a, '--cache-policy')) {
|
|
213
204
|
cacheFlag = true
|
|
214
205
|
const name = flagOf(a, '--cache-policy')!
|
|
@@ -494,7 +485,6 @@ export async function resolveRunOptions(
|
|
|
494
485
|
? { cacheFlags: parsed.cacheFlags }
|
|
495
486
|
: { cache: parsed.cache }),
|
|
496
487
|
...(parsed.remoteRequested ? { remoteRequested: true } : {}),
|
|
497
|
-
...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
|
|
498
488
|
// A laptop reads the shared cache and never writes it (owner,
|
|
499
489
|
// 2026-10-03); naming the remote in `--cache-policy` is asking to.
|
|
500
490
|
...(!parsed.remoteRequested && !onCI(process.env['CI'])
|
|
@@ -524,6 +514,8 @@ export async function resolveRunOptions(
|
|
|
524
514
|
if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
|
|
525
515
|
}
|
|
526
516
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
517
|
+
// `--affected`'s base is the branch's for the cache scope too.
|
|
518
|
+
if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
|
|
527
519
|
|
|
528
520
|
return opts
|
|
529
521
|
}
|
|
@@ -595,9 +587,16 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
595
587
|
const scopeOut: { affectedBase?: string } = {}
|
|
596
588
|
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
597
589
|
if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
590
|
+
// `--dry=json` prints a plan even when there is nothing to run: a script
|
|
591
|
+
// reading stdout broke exactly then (Growth, 2026-10-16).
|
|
592
|
+
const nothingToRun = (): 0 => {
|
|
593
|
+
if (parsed.dry === 'json')
|
|
594
|
+
process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
|
|
595
|
+
return 0
|
|
596
|
+
}
|
|
598
597
|
if ('nothingSelected' in resolved) {
|
|
599
598
|
sayNotice('info', 'vx run', resolved.nothingSelected)
|
|
600
|
-
return
|
|
599
|
+
return nothingToRun()
|
|
601
600
|
}
|
|
602
601
|
const opts = resolved
|
|
603
602
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
@@ -628,11 +627,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
628
627
|
// said no affected project declared a task one did (X-141).
|
|
629
628
|
if (plan.noneAffected !== undefined) {
|
|
630
629
|
sayLine('info', plan.noneAffected, 'vx run')
|
|
631
|
-
return
|
|
630
|
+
return nothingToRun()
|
|
632
631
|
}
|
|
633
632
|
if (opts.selectedByDiff === true) {
|
|
634
633
|
sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
|
|
635
|
-
return
|
|
634
|
+
return nothingToRun()
|
|
636
635
|
}
|
|
637
636
|
return refuse(
|
|
638
637
|
'run',
|