@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.
Files changed (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -23
  3. package/package.json +5 -5
  4. package/schemas/info.json +0 -11
  5. package/schemas/init.json +5 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/cache/archive.ts +89 -2
  8. package/src/cache/cache.ts +396 -176
  9. package/src/cache/file-hashes.ts +2 -14
  10. package/src/cache/fold-memo.ts +7 -3
  11. package/src/cache/index.ts +4 -2
  12. package/src/cache/key-fold.ts +4 -1
  13. package/src/cache/layer.ts +14 -10
  14. package/src/cache/layered-cache.ts +157 -41
  15. package/src/cache/plugin-cache.ts +40 -44
  16. package/src/cache/remote-lane.ts +59 -25
  17. package/src/cache/scoped-cache.ts +3 -80
  18. package/src/cache/tar-stream.ts +12 -7
  19. package/src/cli/format.ts +1 -2
  20. package/src/cli/help.ts +10 -4
  21. package/src/cli/info.ts +2 -19
  22. package/src/cli/init.ts +121 -20
  23. package/src/cli/plan-format.ts +6 -2
  24. package/src/cli/plugin-commands.ts +4 -5
  25. package/src/cli/plugin-templates.ts +1 -1
  26. package/src/cli/run.ts +3 -1
  27. package/src/cli/upgrade.ts +39 -9
  28. package/src/config.ts +13 -10
  29. package/src/exec/sandbox-paths.ts +2 -2
  30. package/src/graph/scheduler.ts +3 -1
  31. package/src/graph/task-graph.ts +15 -11
  32. package/src/index.ts +2 -2
  33. package/src/orchestrator/affected-tasks.ts +0 -18
  34. package/src/orchestrator/doctor.ts +0 -17
  35. package/src/orchestrator/execute-task.ts +1 -1
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/options.ts +6 -8
  38. package/src/orchestrator/plan.ts +7 -1
  39. package/src/orchestrator/plugin-host.ts +6 -5
  40. package/src/orchestrator/plugin.ts +12 -5
  41. package/src/orchestrator/prepare.ts +16 -46
  42. package/src/orchestrator/projects.ts +8 -1
  43. package/src/orchestrator/remote-prefetch.ts +11 -4
  44. package/src/orchestrator/run-context.ts +48 -5
  45. package/src/orchestrator/run-settings.ts +3 -0
  46. package/src/orchestrator/run.ts +10 -26
  47. package/src/orchestrator/task-hash.ts +1 -3
  48. package/src/report/logger.ts +0 -4
  49. package/src/report/summary.ts +14 -13
  50. package/src/settings.ts +38 -15
  51. package/src/util/index.ts +1 -0
  52. package/src/util/paths.ts +42 -33
  53. package/src/util/size.ts +4 -5
  54. package/src/workspace/affected.ts +0 -39
  55. package/src/workspace/config-schema.ts +0 -36
  56. package/src/workspace/index.ts +0 -1
@@ -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
- * Cap on concurrent background PUTs per remote. Keeps a burst of cache
24
- * misses from opening one socket per task; excess uploads queue and drain
25
- * FIFO.
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
- const UPLOAD_CONCURRENCY = 4
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 Set<string>()
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: Promise<{ asked: Set<string>; found: Set<string> | null } | null> | undefined
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
- /** The wire as this run asks it: a branch scope's view of `resolved.remote`. */
137
- remote: RemoteCacheLayer = resolved.remote,
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<boolean> {
158
- if (this.held.has(hash)) return true
159
- if (this.absent.has(hash)) return false
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
- return Boolean(await this.remote.has(hash))
176
+ found = await this.remote.has(hash)
162
177
  } catch (err) {
163
178
  this.report('probe', hash, err)
164
- return false
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<Set<string> | null> {
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 Set)) {
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 Set of the hashes present, or null)`,
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 set = found as Set<string>
194
- for (const h of hashes) (set.has(h) ? this.held : this.absent).add(h)
195
- return set
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 < UPLOAD_CONCURRENCY && this.uploadQueue.length > 0) {
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 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.
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
- }
@@ -122,14 +122,19 @@ class Source {
122
122
  constructor(private readonly reader: AsyncIterator<Uint8Array>) {}
123
123
 
124
124
  private async fill(): Promise<boolean> {
125
- if (this.done) return false
126
- const { done, value } = await this.reader.next()
127
- if (done === true || value === undefined) {
128
- this.done = true
129
- return false
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` moved to util/size.ts beside the parsers (item 658): a run
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 With no package.json: write .vx/workspace.ts and an empty',
262
- ' root vx.config.ts. With one: runs @vzn/vx-migrate, which maps',
263
- ' turbo.json, an Nx graph or package.json scripts to one',
264
- ' vx.config.ts per package (asking native or keep in Turbo/Nx).',
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)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
128
+ /** `812 (1.2 GB)`. */
132
129
  function describeEntries(f: InfoFacts): string {
133
- const max = f.cacheRetention?.maxBytes
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
- // Core reads no package.json (M, 2026-10-14): a repo with one (package.json
3
- // scripts, Turbo, Nx) is `@vzn/vx-migrate`'s, which maps it and installs
4
- // what it writes (`migrate`); without one, the workspace file and an empty
5
- // root project are written here, then the lock (`starter`).
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 { isCompiledBinary, targetVersion } from './upgrade.js'
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
- const manifest = dirs.some((d) => MANIFESTS.some((f) => existsSync(path.join(d, f))))
111
- if (manifest) return migrate(dirs, parsed)
112
- return starter(marked ?? cwd, top === null, parsed)
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
- /** What makes a repo vx-migrate's to map: a package manager's root, a Turbo or Nx config. */
116
- const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'turbo.json', 'turbo.jsonc', 'nx.json']
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 package.json: `.vx/workspace.ts` declaring no plugin and a root
155
- * `vx.config.ts` with no task, then the lock. `@vzn/vx` resolves to the
156
- * running vx (core-alias.ts), so the two load with nothing installed.
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(root: string, noGit: boolean, args: InitArgs): Promise<number> {
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 package.json here`,
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 package.json', files, kept, replaced, todos: [], notes: [], next })}\n`,
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 package.json: one project, the root']]
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 repo with a package.json is `@vzn/vx-migrate`'s, at the latest
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), {
@@ -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) hitParts.push(`${remote} remote`)
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 }] = await Promise.all([
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,