@vzn/vx 0.0.643 → 0.0.645
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/LICENSE +1 -1
- package/README.md +15 -23
- package/package.json +5 -5
- package/schemas/info.json +0 -11
- package/schemas/init.json +5 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/cache/archive.ts +89 -2
- package/src/cache/cache.ts +396 -176
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +4 -2
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +14 -10
- package/src/cache/layered-cache.ts +157 -41
- package/src/cache/plugin-cache.ts +40 -44
- package/src/cache/remote-lane.ts +59 -25
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +10 -4
- package/src/cli/info.ts +2 -19
- package/src/cli/init.ts +121 -20
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +4 -5
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +3 -1
- package/src/cli/upgrade.ts +39 -9
- package/src/config.ts +13 -10
- package/src/exec/sandbox-paths.ts +2 -2
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +15 -11
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +6 -8
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +6 -5
- package/src/orchestrator/plugin.ts +12 -5
- package/src/orchestrator/prepare.ts +16 -46
- package/src/orchestrator/projects.ts +8 -1
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +48 -5
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +10 -26
- package/src/orchestrator/task-hash.ts +1 -3
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +14 -13
- package/src/settings.ts +38 -15
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-schema.ts +0 -36
- package/src/workspace/index.ts +0 -1
package/src/cache/remote-lane.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// class with the endpoint named. The order across remotes, the local store
|
|
5
5
|
// and provenance are `LayeredCache`'s; nothing here knows another remote.
|
|
6
6
|
|
|
7
|
+
import { machineParallelism } from '../util/index.js'
|
|
7
8
|
import type { RemoteKeyTraffic, RemoteTraffic } from './cache.js'
|
|
8
|
-
import type { RemoteCacheLayer } from './layered-cache.js'
|
|
9
|
+
import type { ArtifactInfo, RemoteCacheLayer } from './layered-cache.js'
|
|
9
10
|
|
|
10
11
|
/** One remote, resolved: its wire and what the run's policy and its `access` let it do. */
|
|
11
12
|
export interface ResolvedRemote {
|
|
@@ -20,11 +21,13 @@ export interface ResolvedRemote {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Requests at once to a remote, by default: pnpm's network concurrency
|
|
25
|
+
* (Bun's is a fixed 64). A request waits on the network, not a core, so
|
|
26
|
+
* the cap follows cores only past 21 of them.
|
|
26
27
|
*/
|
|
27
|
-
|
|
28
|
+
export function networkConcurrency(): number {
|
|
29
|
+
return Math.min(96, Math.max(machineParallelism() * 3, 64))
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
/** The seam call a failure happened in, as the warning names it. */
|
|
30
33
|
export type RemoteOp = 'probe' | 'download' | 'upload'
|
|
@@ -45,6 +48,14 @@ function invalidRemoteResult(what: string): Error {
|
|
|
45
48
|
)
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/** A plugin's `ArtifactInfo`, keeping only the fields that are finite, non-negative numbers; null when it is no object. */
|
|
52
|
+
function artifactInfo(value: unknown): ArtifactInfo | null {
|
|
53
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
|
54
|
+
const { size, durationMs } = value as Record<string, unknown>
|
|
55
|
+
const ok = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0
|
|
56
|
+
return { ...(ok(size) ? { size } : {}), ...(ok(durationMs) ? { durationMs } : {}) }
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
function isBody(value: unknown): value is Blob | Response {
|
|
49
60
|
return value instanceof Blob || value instanceof Response
|
|
50
61
|
}
|
|
@@ -103,14 +114,16 @@ export class RemoteLane {
|
|
|
103
114
|
*/
|
|
104
115
|
private readonly absent = new Set<string>()
|
|
105
116
|
/** What a `hasMany` said this remote holds, as `absent` records the rest. */
|
|
106
|
-
private readonly held = new
|
|
117
|
+
private readonly held = new Map<string, ArtifactInfo>()
|
|
107
118
|
|
|
108
119
|
/**
|
|
109
120
|
* A remote the run writes but does not read (`--force`): which of the
|
|
110
121
|
* run's up-front keys it already holds, from one `hasMany`. `found` is
|
|
111
122
|
* null when the probe failed.
|
|
112
123
|
*/
|
|
113
|
-
private presence:
|
|
124
|
+
private presence:
|
|
125
|
+
| Promise<{ asked: Set<string>; found: Map<string, ArtifactInfo> | null } | null>
|
|
126
|
+
| undefined
|
|
114
127
|
|
|
115
128
|
private readonly uploadQueue: Array<() => Promise<void>> = []
|
|
116
129
|
private activeUploads = 0
|
|
@@ -133,11 +146,11 @@ export class RemoteLane {
|
|
|
133
146
|
constructor(
|
|
134
147
|
resolved: ResolvedRemote,
|
|
135
148
|
private readonly traffic: RemoteTraffic,
|
|
136
|
-
/**
|
|
137
|
-
|
|
149
|
+
/** Background PUTs at once; excess uploads queue and drain FIFO. */
|
|
150
|
+
private readonly uploadConcurrency: number = networkConcurrency(),
|
|
138
151
|
) {
|
|
139
152
|
this.resolved = resolved
|
|
140
|
-
this.remote = remote
|
|
153
|
+
this.remote = resolved.remote
|
|
141
154
|
this.onError = resolved.onError
|
|
142
155
|
this.endpoint = printableEndpoint(resolved.remote.endpoint)
|
|
143
156
|
}
|
|
@@ -154,15 +167,28 @@ export class RemoteLane {
|
|
|
154
167
|
* Existence probe; a failure is a miss. A key a `hasMany` this run
|
|
155
168
|
* answered for is not asked again: `--dry` batches its keys first.
|
|
156
169
|
*/
|
|
157
|
-
async has(hash: string): Promise<
|
|
158
|
-
|
|
159
|
-
if (
|
|
170
|
+
async has(hash: string): Promise<ArtifactInfo | null> {
|
|
171
|
+
const held = this.held.get(hash)
|
|
172
|
+
if (held !== undefined) return held
|
|
173
|
+
if (this.absent.has(hash)) return null
|
|
174
|
+
let found: unknown
|
|
160
175
|
try {
|
|
161
|
-
|
|
176
|
+
found = await this.remote.has(hash)
|
|
162
177
|
} catch (err) {
|
|
163
178
|
this.report('probe', hash, err)
|
|
164
|
-
return
|
|
179
|
+
return null
|
|
165
180
|
}
|
|
181
|
+
if (found === null || found === undefined) return null
|
|
182
|
+
const info = artifactInfo(found)
|
|
183
|
+
if (info === null)
|
|
184
|
+
this.report(
|
|
185
|
+
'probe',
|
|
186
|
+
hash,
|
|
187
|
+
invalidRemoteResult(
|
|
188
|
+
`has() resolved ${describeValue(found)} (expected { size, durationMs } or null)`,
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
return info
|
|
166
192
|
}
|
|
167
193
|
|
|
168
194
|
/**
|
|
@@ -170,7 +196,7 @@ export class RemoteLane {
|
|
|
170
196
|
* rest as absent; `null` when this remote cannot batch or the probe
|
|
171
197
|
* failed, which the caller reads as "no batch info".
|
|
172
198
|
*/
|
|
173
|
-
async hasMany(hashes: readonly string[]): Promise<
|
|
199
|
+
async hasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
174
200
|
if (this.remote.hasMany === undefined) return null
|
|
175
201
|
let found: unknown
|
|
176
202
|
try {
|
|
@@ -180,19 +206,26 @@ export class RemoteLane {
|
|
|
180
206
|
return null
|
|
181
207
|
}
|
|
182
208
|
if (found === null || found === undefined) return null
|
|
183
|
-
if (!(found instanceof
|
|
209
|
+
if (!(found instanceof Map)) {
|
|
184
210
|
this.report(
|
|
185
211
|
'probe',
|
|
186
212
|
batchOf(hashes),
|
|
187
213
|
invalidRemoteResult(
|
|
188
|
-
`hasMany() resolved ${describeValue(found)} (expected a
|
|
214
|
+
`hasMany() resolved ${describeValue(found)} (expected a Map of the hashes present, or null)`,
|
|
189
215
|
),
|
|
190
216
|
)
|
|
191
217
|
return null
|
|
192
218
|
}
|
|
193
|
-
const
|
|
194
|
-
for (const h of hashes)
|
|
195
|
-
|
|
219
|
+
const out = new Map<string, ArtifactInfo>()
|
|
220
|
+
for (const h of hashes) {
|
|
221
|
+
const info = found.has(h) ? (artifactInfo(found.get(h)) ?? {}) : undefined
|
|
222
|
+
if (info === undefined) this.absent.add(h)
|
|
223
|
+
else {
|
|
224
|
+
this.held.set(h, info)
|
|
225
|
+
out.set(h, info)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
196
229
|
}
|
|
197
230
|
|
|
198
231
|
/** The artifact's body, or null on a miss, a known absence, a failure or a malformed answer. */
|
|
@@ -248,14 +281,14 @@ export class RemoteLane {
|
|
|
248
281
|
const probed = await this.presence
|
|
249
282
|
if (probed !== undefined && probed !== null && probed.found !== null && probed.asked.has(hash))
|
|
250
283
|
return probed.found.has(hash)
|
|
251
|
-
return this.has(hash)
|
|
284
|
+
return (await this.has(hash)) !== null
|
|
252
285
|
}
|
|
253
286
|
|
|
254
287
|
/** Queue a PUT in this remote's bounded pool; `body` is asked for when the job starts. */
|
|
255
288
|
upload(
|
|
256
289
|
hash: string,
|
|
257
290
|
durationMs: number,
|
|
258
|
-
body: () => { body: Blob; release?: () => Promise<void> }
|
|
291
|
+
body: () => Promise<{ body: Blob; release?: () => Promise<void> }>,
|
|
259
292
|
tally: { count: number; bytes: number; ms: number; failed: number },
|
|
260
293
|
): void {
|
|
261
294
|
const key = this.trafficOf(hash)
|
|
@@ -265,10 +298,11 @@ export class RemoteLane {
|
|
|
265
298
|
let release: (() => Promise<void>) | undefined
|
|
266
299
|
try {
|
|
267
300
|
if (this.stopped) return
|
|
301
|
+
if (this.held.has(hash)) return
|
|
268
302
|
if (!this.read && (await this.holds(hash))) return
|
|
269
303
|
if (this.stopped) return
|
|
270
304
|
const start = performance.now()
|
|
271
|
-
const got = body()
|
|
305
|
+
const got = await body()
|
|
272
306
|
release = got.release
|
|
273
307
|
await this.remote.put(hash, got.body, { durationMs })
|
|
274
308
|
tally.count++
|
|
@@ -369,7 +403,7 @@ export class RemoteLane {
|
|
|
369
403
|
}
|
|
370
404
|
|
|
371
405
|
private pump(): void {
|
|
372
|
-
while (this.activeUploads <
|
|
406
|
+
while (this.activeUploads < this.uploadConcurrency && this.uploadQueue.length > 0) {
|
|
373
407
|
const job = this.uploadQueue.shift()!
|
|
374
408
|
this.activeUploads++
|
|
375
409
|
// Jobs never reject (each wraps its PUT in the never-fail guard).
|
|
@@ -1,87 +1,10 @@
|
|
|
1
|
-
// A
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
1
|
+
// A scope keys only what plugins store themselves (ctx.cache, run records):
|
|
2
|
+
// task keys reach every remote plain, and a remote decides which branch a
|
|
3
|
+
// read may see (VZN, 2026-10-20).
|
|
7
4
|
|
|
8
5
|
import { xxh3hex } from '../util/index.js'
|
|
9
|
-
import type { RemoteCacheLayer, RemoteKey } from './layered-cache.js'
|
|
10
6
|
|
|
11
7
|
/** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
|
|
12
8
|
export function scopedKey(scope: string, hash: string): string {
|
|
13
9
|
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
14
10
|
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
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.
|
|
20
|
-
*/
|
|
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 }),
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -122,14 +122,19 @@ class Source {
|
|
|
122
122
|
constructor(private readonly reader: AsyncIterator<Uint8Array>) {}
|
|
123
123
|
|
|
124
124
|
private async fill(): Promise<boolean> {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.done
|
|
129
|
-
|
|
125
|
+
// An empty chunk is skipped, never reported as data: the callers read
|
|
126
|
+
// `chunks[0]` after a true (the tar a tree packs back yields them).
|
|
127
|
+
for (;;) {
|
|
128
|
+
if (this.done) return false
|
|
129
|
+
const { done, value } = await this.reader.next()
|
|
130
|
+
if (done === true || value === undefined) {
|
|
131
|
+
this.done = true
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
if (value.byteLength === 0) continue
|
|
135
|
+
this.chunks.push(value)
|
|
136
|
+
return true
|
|
130
137
|
}
|
|
131
|
-
if (value.byteLength > 0) this.chunks.push(value)
|
|
132
|
-
return true
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
|
package/src/cli/format.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// `formatBytes`
|
|
2
|
-
// names what the workspace's `cacheRetention` evicted, and the orchestrator
|
|
1
|
+
// `formatBytes` lives in util/size.ts beside the parsers: the orchestrator
|
|
3
2
|
// may not import the CLI.
|
|
4
3
|
export { formatBytes } from '../util/index.js'
|
package/src/cli/help.ts
CHANGED
|
@@ -163,6 +163,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
163
163
|
' .vx/cache, entries in ~/.vx/<id>/cache.',
|
|
164
164
|
' --cache-download <mode> all (default) | toplevel | none:',
|
|
165
165
|
' where remote outputs land.',
|
|
166
|
+
' --cache-concurrency-download <n>',
|
|
167
|
+
' Remote downloads at once.',
|
|
168
|
+
' --cache-concurrency-upload <n>',
|
|
169
|
+
' Uploads at once, per remote.',
|
|
170
|
+
' --cache-concurrency-restore <n>',
|
|
171
|
+
' Local hits restoring at once.',
|
|
166
172
|
' --retries <n> Up to <n> more runs of a failed task, for',
|
|
167
173
|
' tasks without their own exec.retries. Not',
|
|
168
174
|
' folded into cache keys.',
|
|
@@ -258,10 +264,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
258
264
|
' --cache-dir <path> Read the cache a run with the same flag uses.',
|
|
259
265
|
'',
|
|
260
266
|
'Migration:',
|
|
261
|
-
' vx init
|
|
262
|
-
'
|
|
263
|
-
'
|
|
264
|
-
'
|
|
267
|
+
' vx init Install vx globally and as the root devDependency, then',
|
|
268
|
+
' write .vx/workspace.ts and an empty root vx.config.ts. In a',
|
|
269
|
+
' Turbo or Nx repo: runs @vzn/vx-migrate, which maps turbo.json',
|
|
270
|
+
' or the Nx graph to vx (asking native or keep).',
|
|
265
271
|
' --dry Print the generated files instead of writing them.',
|
|
266
272
|
' --format json With --dry: the plan as one JSON document (schemas/init.json).',
|
|
267
273
|
' --force Overwrite existing vx.config.* files.',
|
package/src/cli/info.ts
CHANGED
|
@@ -100,9 +100,6 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
|
|
|
100
100
|
...(Object.values(f.settings).some((s) => s.source !== 'default')
|
|
101
101
|
? ([['settings', describeSettings(f.settings)]] as [string, string][])
|
|
102
102
|
: []),
|
|
103
|
-
...(f.cacheRetention !== null
|
|
104
|
-
? ([['cache retention', describeRetention(f.cacheRetention)]] as [string, string][])
|
|
105
|
-
: []),
|
|
106
103
|
// Only when there is something to say.
|
|
107
104
|
...(f.orphans.artifacts > 0
|
|
108
105
|
? ([
|
|
@@ -128,14 +125,9 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
|
|
|
128
125
|
return ui.report('vx info', rows).lines(colors).join('\n')
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
/** `812 (1.2 GB)
|
|
128
|
+
/** `812 (1.2 GB)`. */
|
|
132
129
|
function describeEntries(f: InfoFacts): string {
|
|
133
|
-
|
|
134
|
-
const size =
|
|
135
|
-
max === undefined
|
|
136
|
-
? formatBytes(f.cacheBytes)
|
|
137
|
-
: `${formatBytes(f.cacheBytes)} of ${formatBytes(max)}, ${Math.round((f.cacheBytes / max) * 100)}%`
|
|
138
|
-
return `${f.cacheEntries} (${size})`
|
|
130
|
+
return `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`
|
|
139
131
|
}
|
|
140
132
|
|
|
141
133
|
/**
|
|
@@ -152,15 +144,6 @@ function describeSettings(settings: InfoFacts['settings']): string {
|
|
|
152
144
|
.join(', ')
|
|
153
145
|
}
|
|
154
146
|
|
|
155
|
-
/** What a run's end evicts: `entries unused for 30d, and the oldest entries past 10G`. */
|
|
156
|
-
function describeRetention(r: NonNullable<InfoFacts['cacheRetention']>): string {
|
|
157
|
-
const parts = [
|
|
158
|
-
...(r.olderThan !== undefined ? [`entries unused for ${r.olderThan}`] : []),
|
|
159
|
-
...(r.maxSize !== undefined ? [`the oldest entries past ${r.maxSize}`] : []),
|
|
160
|
-
]
|
|
161
|
-
return `each run evicts ${parts.join(', and ')}`
|
|
162
|
-
}
|
|
163
|
-
|
|
164
147
|
/** A path under the home directory as `~/…`: shorter, and no user name in a pasted report. */
|
|
165
148
|
function tildePath(p: string): string {
|
|
166
149
|
const home = os.homedir()
|
package/src/cli/init.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// what it writes (`migrate`);
|
|
5
|
-
//
|
|
2
|
+
// It installs vx globally and, where a package.json is, as its devDependency
|
|
3
|
+
// (M, 2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
|
|
4
|
+
// and installs what it writes (`migrate`); any other gets the workspace file
|
|
5
|
+
// and an empty root project, then the lock (`starter`).
|
|
6
6
|
// `vx init --plugin <seam>` writes a runnable plugin for one seam and its
|
|
7
7
|
// test instead (plugin-templates.ts, the examples the gate runs).
|
|
8
8
|
|
|
@@ -13,7 +13,14 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
|
|
|
13
13
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { lockAfterMigration } from './lock.js'
|
|
15
15
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isCompiledBinary,
|
|
18
|
+
projectManager,
|
|
19
|
+
startedVersion,
|
|
20
|
+
targetVersion,
|
|
21
|
+
upgradeArgv,
|
|
22
|
+
type ManagedInstall,
|
|
23
|
+
} from './upgrade.js'
|
|
17
24
|
import { VERSION } from '../version.js'
|
|
18
25
|
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
26
|
import {
|
|
@@ -107,13 +114,96 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
107
114
|
dirs.push(d)
|
|
108
115
|
if (d === top || path.dirname(d) === d) break
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
|
|
118
|
+
return migrate(dirs, parsed)
|
|
119
|
+
}
|
|
120
|
+
// The topmost package.json: a monorepo's root, wherever below it init ran.
|
|
121
|
+
const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
|
|
122
|
+
return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
|
|
126
|
+
const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
|
|
130
|
+
* with the manager that started init, so a bare `vx` is on PATH, unless
|
|
131
|
+
* the global vx already answers `version`; and, where `pkgRoot` has a
|
|
132
|
+
* package.json, as its devDependency with the repo's own manager.
|
|
133
|
+
*/
|
|
134
|
+
export function initInstalls(
|
|
135
|
+
version: string,
|
|
136
|
+
userAgent: string | undefined,
|
|
137
|
+
pkgRoot: string | null,
|
|
138
|
+
globalAt: string | undefined,
|
|
139
|
+
execPath = process.execPath,
|
|
140
|
+
): { argv: string[]; cwd: string | null }[] {
|
|
141
|
+
const out: { argv: string[]; cwd: string | null }[] = []
|
|
142
|
+
if (globalAt !== version) {
|
|
143
|
+
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm'
|
|
144
|
+
const manager = runner as ManagedInstall['manager']
|
|
145
|
+
out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
|
|
146
|
+
}
|
|
147
|
+
if (pkgRoot !== null) {
|
|
148
|
+
const install: ManagedInstall = {
|
|
149
|
+
manager: projectManager(pkgRoot, execPath),
|
|
150
|
+
scope: 'project',
|
|
151
|
+
cwd: pkgRoot,
|
|
152
|
+
field: 'devDependencies',
|
|
153
|
+
}
|
|
154
|
+
out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The version the global `vx` on PATH answers: not a runner's cache, not a project's bin. */
|
|
160
|
+
function globalVersion(): string | undefined {
|
|
161
|
+
for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) {
|
|
162
|
+
const p = dir.split(path.sep).join('/')
|
|
163
|
+
if (p.includes('/_npx/') || /\/(bunx|dlx)-/.test(p) || p.includes('/node_modules/.bin'))
|
|
164
|
+
continue
|
|
165
|
+
const bin = path.join(dir, 'vx')
|
|
166
|
+
if (existsSync(bin)) return startedVersion(bin)?.split(' ')[1]
|
|
167
|
+
}
|
|
168
|
+
return undefined
|
|
113
169
|
}
|
|
114
170
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
|
|
173
|
+
* had) installs nothing: the release would replace the code under test.
|
|
174
|
+
*/
|
|
175
|
+
async function install(pkgRoot: string | null, dry: boolean): Promise<string[]> {
|
|
176
|
+
if (VERSION === '0.0.0') return []
|
|
177
|
+
const version = await targetVersion(undefined, 'vx init')
|
|
178
|
+
const steps = initInstalls(
|
|
179
|
+
version,
|
|
180
|
+
process.env['npm_config_user_agent'],
|
|
181
|
+
pkgRoot,
|
|
182
|
+
globalVersion(),
|
|
183
|
+
)
|
|
184
|
+
if (dry) return steps.map((s) => s.argv.join(' '))
|
|
185
|
+
for (const s of steps) {
|
|
186
|
+
const bin = Bun.which(s.argv[0]!, { PATH: process.env['PATH'] ?? '' })
|
|
187
|
+
if (bin === null) {
|
|
188
|
+
throw new UserError(
|
|
189
|
+
`vx init: ${s.argv[0]} is not on PATH; run it yourself: ${s.argv.join(' ')}`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
ui.print(
|
|
193
|
+
'out',
|
|
194
|
+
ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
|
|
195
|
+
)
|
|
196
|
+
const proc = Bun.spawnSync({
|
|
197
|
+
cmd: [bin, ...s.argv.slice(1)],
|
|
198
|
+
cwd: s.cwd ?? process.cwd(),
|
|
199
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
200
|
+
})
|
|
201
|
+
if (proc.exitCode !== 0) {
|
|
202
|
+
throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return steps.map((s) => s.argv.join(' '))
|
|
206
|
+
}
|
|
117
207
|
|
|
118
208
|
/** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
|
|
119
209
|
async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
|
|
@@ -151,17 +241,23 @@ const PROJECT =
|
|
|
151
241
|
"import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
|
|
152
242
|
|
|
153
243
|
/**
|
|
154
|
-
* No
|
|
155
|
-
* `vx.config.ts` with no task, then the lock.
|
|
156
|
-
* running vx (core-alias.ts), so the two load
|
|
157
|
-
* A file already here is kept; `--force` replaces it.
|
|
244
|
+
* No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
|
|
245
|
+
* no plugin and a root `vx.config.ts` with no task, then the lock.
|
|
246
|
+
* `@vzn/vx` resolves to the running vx (core-alias.ts), so the two load
|
|
247
|
+
* whatever got installed. A file already here is kept; `--force` replaces it.
|
|
158
248
|
*/
|
|
159
|
-
async function starter(
|
|
249
|
+
async function starter(
|
|
250
|
+
root: string,
|
|
251
|
+
pkgRoot: string | null,
|
|
252
|
+
noGit: boolean,
|
|
253
|
+
args: InitArgs,
|
|
254
|
+
): Promise<number> {
|
|
160
255
|
if (args.mode !== undefined) {
|
|
161
256
|
throw new UserError(
|
|
162
|
-
`vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no
|
|
257
|
+
`vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
|
|
163
258
|
)
|
|
164
259
|
}
|
|
260
|
+
const installs = args.json === true || args.dry ? await install(pkgRoot, true) : []
|
|
165
261
|
const ext = args.mjs ? 'mjs' : 'ts'
|
|
166
262
|
const config = `vx.config.${ext}`
|
|
167
263
|
const files: { path: string; contents: string }[] = []
|
|
@@ -185,16 +281,17 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
185
281
|
] as const) {
|
|
186
282
|
if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
|
|
187
283
|
}
|
|
188
|
-
const vx = vxInvocation(process.env['npm_config_user_agent'], false)
|
|
284
|
+
const vx = VERSION === '0.0.0' ? vxInvocation(process.env['npm_config_user_agent'], false) : 'vx'
|
|
189
285
|
const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
|
|
190
286
|
if (args.json === true) {
|
|
191
287
|
process.stdout.write(
|
|
192
|
-
`${JSON.stringify({ dry: true, source: 'no
|
|
288
|
+
`${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
|
|
193
289
|
)
|
|
194
290
|
return 0
|
|
195
291
|
}
|
|
196
|
-
const rows: ui.FieldRow[] = [['from', 'no
|
|
292
|
+
const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
|
|
197
293
|
if (args.dry) rows.push(['dry run', 'nothing written'])
|
|
294
|
+
if (installs.length > 0) rows.push(['install', ...installs])
|
|
198
295
|
if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
|
|
199
296
|
if (kept.length > 0) rows.push(['kept', ...kept])
|
|
200
297
|
if (replaced.length > 0) rows.push(['replaced', ...replaced])
|
|
@@ -203,6 +300,8 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
203
300
|
ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
|
|
204
301
|
return 0
|
|
205
302
|
}
|
|
303
|
+
const ran = await install(pkgRoot, false)
|
|
304
|
+
if (ran.length > 0) rows.push(['installed', ...ran])
|
|
206
305
|
for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
|
|
207
306
|
for (const f of replaced) await unlink(path.join(root, f))
|
|
208
307
|
// The lock that follows loads the workspace file just written.
|
|
@@ -217,7 +316,7 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
217
316
|
}
|
|
218
317
|
|
|
219
318
|
/**
|
|
220
|
-
* A
|
|
319
|
+
* A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
|
|
221
320
|
* release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
|
|
222
321
|
* at its own version, so init always lands on latest with all of them
|
|
223
322
|
* together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
|
|
@@ -232,6 +331,8 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
|
|
|
232
331
|
args.mode !== undefined && `--${args.mode}`,
|
|
233
332
|
args.json === true && '--format=json',
|
|
234
333
|
].filter((f): f is string => typeof f === 'string')
|
|
334
|
+
// vx-migrate installs the project's own vx.
|
|
335
|
+
if (!args.dry) await install(null, false)
|
|
235
336
|
// An exact version: `bun x` may answer `@latest` from its cache.
|
|
236
337
|
const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
|
|
237
338
|
return Bun.spawn(migrateCommand(dirs, flags, latest), {
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry` (human / JSON).
|
|
2
2
|
|
|
3
3
|
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
4
|
+
import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -65,7 +65,11 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
65
65
|
|
|
66
66
|
const hitParts: string[] = []
|
|
67
67
|
if (local > 0) hitParts.push(`${local} local`)
|
|
68
|
-
if (remote > 0)
|
|
68
|
+
if (remote > 0) {
|
|
69
|
+
// The remote's sizes, summed: what restoring the hits would download.
|
|
70
|
+
const bytes = real.reduce((sum, t) => sum + (t.remoteBytes ?? 0), 0)
|
|
71
|
+
hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
|
|
72
|
+
}
|
|
69
73
|
const hits = local + remote
|
|
70
74
|
const summary: string[] = [`${real.length} task(s) planned`]
|
|
71
75
|
if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
|
|
@@ -138,13 +138,11 @@ async function verbCache(
|
|
|
138
138
|
ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
|
|
139
139
|
plugin: string,
|
|
140
140
|
): Promise<PluginCache> {
|
|
141
|
-
const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] =
|
|
142
|
-
import('../cache/index.js'),
|
|
143
|
-
import('../orchestrator/index.js'),
|
|
144
|
-
])
|
|
141
|
+
const [{ Cache, pluginCache }, { cacheRefs, resolveCache, resolveCacheScope }] =
|
|
142
|
+
await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
|
|
145
143
|
const scope = resolveCacheScope(
|
|
146
144
|
ws.workspaceRoot,
|
|
147
|
-
ws.branch.scope,
|
|
145
|
+
ws.branch.trusted === true ? null : ws.branch.scope,
|
|
148
146
|
ws.plugins.some((p) => p.cache !== undefined),
|
|
149
147
|
configuredBaseBranch(ws.workspaceConfig),
|
|
150
148
|
)
|
|
@@ -162,6 +160,7 @@ async function verbCache(
|
|
|
162
160
|
cacheDir: ws.cacheDir,
|
|
163
161
|
warn,
|
|
164
162
|
scope,
|
|
163
|
+
refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
|
|
165
164
|
localCache,
|
|
166
165
|
policy: VERB_POLICY,
|
|
167
166
|
remotes,
|