@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -104,33 +104,50 @@ export function versionSpec(tag: string | undefined): string {
104
104
  const v = tag.startsWith('v') ? tag.slice(1) : tag
105
105
  if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
106
106
  throw new UserError(
107
- `vx upgrade: ${tag} is not a version (expected v<major>.<minor>.<patch>)`,
107
+ `vx upgrade: ${JSON.stringify(tag)} is not a version (expected v<major>.<minor>.<patch>)`,
108
108
  'VX_E_USAGE',
109
109
  )
110
110
  }
111
111
  return v
112
112
  }
113
113
 
114
+ /**
115
+ * How long a registry may stay silent, before its headers or between two
116
+ * chunks of a body, before vx gives up: one that took the connection and
117
+ * never answered hung `vx upgrade` and `vx init` with no output. A slow
118
+ * download that keeps moving is never cut.
119
+ */
120
+ const SILENCE_MS = 30_000
121
+
122
+ const silence = (ms: number): Error => new Error(`no answer in ${ms / 1000} s`)
123
+
114
124
  /**
115
125
  * `fetch` on a box that cannot reach the host rejects with Bun's own
116
126
  * `TypeError` ("Unable to connect. Is the computer able to access the
117
127
  * url?", "Was there a typo in the url or port?"), which the CLI printed
118
128
  * as an internal error with a stack (a network namespace without a
119
- * route, 2026-09-16). One line, the host and the reason.
129
+ * route, 2026-09-16). One line, the host and the reason. `quiet` aborts
130
+ * the request after `silenceMs` without headers; the body read
131
+ * (`bodyOrRefuse`) aborts it too.
120
132
  */
121
133
  async function fetchOrRefuse(
122
134
  url: string,
123
135
  init: RequestInit,
124
136
  what: string,
125
- verb = 'vx upgrade',
137
+ verb: string,
138
+ quiet: AbortController,
139
+ silenceMs: number,
126
140
  ): Promise<Response> {
141
+ const timer = setTimeout(() => quiet.abort(silence(silenceMs)), silenceMs)
127
142
  try {
128
- return await fetch(url, init)
143
+ return await fetch(url, { ...init, signal: quiet.signal })
129
144
  } catch (err) {
130
145
  const message = err instanceof Error ? err.message : String(err)
131
146
  throw new UserError(
132
147
  `${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
133
148
  )
149
+ } finally {
150
+ clearTimeout(timer)
134
151
  }
135
152
  }
136
153
 
@@ -139,17 +156,36 @@ async function fetchOrRefuse(
139
156
  * connection drop before the body does (a proxy that cuts a transfer):
140
157
  * Bun rejects the read with its own `TypeError` (`ECONNRESET`, "The socket
141
158
  * connection was closed unexpectedly"), which `fetchOrRefuse` never sees
142
- * and the CLI printed as an internal error with a stack.
159
+ * and the CLI printed as an internal error with a stack. A body silent for
160
+ * `silenceMs` between chunks is the same refusal, and its request aborted.
143
161
  */
144
- async function readOrRefuse<T>(
145
- read: () => Promise<T>,
162
+ async function bodyOrRefuse<T>(
163
+ res: Response,
164
+ parse: (bytes: Uint8Array) => T,
146
165
  url: string,
147
166
  what: string,
148
- verb = 'vx upgrade',
167
+ verb: string,
168
+ quiet: AbortController,
169
+ silenceMs: number,
149
170
  ): Promise<T> {
150
171
  try {
151
- return await read()
172
+ const parts: Uint8Array[] = []
173
+ const reader = res.body?.getReader()
174
+ for (;;) {
175
+ if (reader === undefined) break
176
+ let timer: Timer | undefined
177
+ const next = await Promise.race([
178
+ reader.read(),
179
+ new Promise<never>((_, reject) => {
180
+ timer = setTimeout(() => reject(silence(silenceMs)), silenceMs)
181
+ }),
182
+ ]).finally(() => clearTimeout(timer))
183
+ if (next.done) break
184
+ parts.push(next.value)
185
+ }
186
+ return parse(new Uint8Array(Bun.concatArrayBuffers(parts)))
152
187
  } catch (err) {
188
+ quiet.abort()
153
189
  const message = err instanceof Error ? err.message : String(err)
154
190
  throw new UserError(
155
191
  `${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
@@ -163,8 +199,10 @@ export async function fetchManifest(
163
199
  spec: string,
164
200
  verb = 'vx upgrade',
165
201
  registry: Registry = resolveRegistry(pkg.split('/')[0]!),
202
+ silenceMs = SILENCE_MS,
166
203
  ): Promise<unknown> {
167
204
  const url = `${registry.url}${pkg}/${spec}`
205
+ const quiet = new AbortController()
168
206
  const res = await fetchOrRefuse(
169
207
  url,
170
208
  {
@@ -177,15 +215,19 @@ export async function fetchManifest(
177
215
  },
178
216
  'read the release',
179
217
  verb,
218
+ quiet,
219
+ silenceMs,
180
220
  )
181
221
  if (!res.ok) {
222
+ quiet.abort()
182
223
  throw new UserError(
183
224
  `${verb}: could not read ${pkg}@${spec} from ${new URL(url).host} (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}${res.status === 401 || res.status === 403 ? ' — check the registry credentials in .npmrc or bunfig.toml' : ''}`,
184
225
  )
185
226
  }
186
227
  // A body that is not JSON (a captive portal's page, a proxy's error
187
228
  // page served with a 200) is the same refusal as a cut one.
188
- return await readOrRefuse(() => res.json(), url, 'read the release', verb)
229
+ const json = (bytes: Uint8Array): unknown => JSON.parse(new TextDecoder().decode(bytes))
230
+ return await bodyOrRefuse(res, json, url, 'read the release', verb, quiet, silenceMs)
189
231
  }
190
232
 
191
233
  function authHeader(registry: Registry, url: string): Record<string, string> {
@@ -219,11 +261,16 @@ export function verifiedRelease(
219
261
  const version = typeof m.version === 'string' ? m.version : ''
220
262
  const tarball = m.dist?.tarball
221
263
  const integrity = m.dist?.integrity
222
- if (version === '' || typeof tarball !== 'string' || !tarball.startsWith('https://')) {
264
+ if (version === '' || typeof tarball !== 'string') {
223
265
  throw new UserError(
224
266
  `vx upgrade: the npm registry published no tarball for ${pkg}${version === '' ? '' : `@${version}`} — nothing replaced`,
225
267
  )
226
268
  }
269
+ if (!tarball.startsWith('https://')) {
270
+ throw new UserError(
271
+ `vx upgrade: the npm registry's tarball for ${pkg}@${version} is not https (${tarball}) — nothing replaced; serve the mirror's tarballs over https`,
272
+ )
273
+ }
227
274
  if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(integrity)) {
228
275
  throw new UserError(
229
276
  `vx upgrade: the npm registry published no SHA-512 integrity for ${pkg}@${version} — nothing to verify the download against, so it is not attempted`,
@@ -271,20 +318,35 @@ export function verifiedRelease(
271
318
  }
272
319
 
273
320
  /** Download `release.tarball` and check its bytes against the signed SHA-512. */
274
- async function downloadVerified(release: SignedRelease, registry: Registry): Promise<Uint8Array> {
321
+ async function downloadVerified(
322
+ release: SignedRelease,
323
+ registry: Registry,
324
+ silenceMs: number,
325
+ ): Promise<Uint8Array> {
275
326
  const url = release.tarball
327
+ const quiet = new AbortController()
276
328
  const res = await fetchOrRefuse(
277
329
  url,
278
330
  { redirect: 'follow', headers: authHeader(registry, url) },
279
331
  'download the release',
332
+ 'vx upgrade',
333
+ quiet,
334
+ silenceMs,
280
335
  )
281
336
  if (!res.ok) {
337
+ quiet.abort()
282
338
  throw new UserError(
283
339
  `vx upgrade: download failed (${res.status}) — ${url} — nothing replaced; re-run`,
284
340
  )
285
341
  }
286
- const bytes = new Uint8Array(
287
- await readOrRefuse(() => res.arrayBuffer(), url, 'download the release'),
342
+ const bytes = await bodyOrRefuse(
343
+ res,
344
+ (b) => b,
345
+ url,
346
+ 'download the release',
347
+ 'vx upgrade',
348
+ quiet,
349
+ silenceMs,
288
350
  )
289
351
  const got = `sha512-${new Bun.CryptoHasher('sha512').update(bytes).digest('base64')}`
290
352
  if (got !== release.integrity) {
@@ -328,22 +390,25 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
328
390
  }
329
391
 
330
392
  /**
331
- * The binary of `pkg@spec`, verified: the registry document's signature,
332
- * then the tarball's integrity, then the extraction, each refusing before
333
- * the next step runs. A signed version other than the one asked, or as
393
+ * The release of `pkg@spec`, its registry document verified, nothing
394
+ * downloaded yet: `download` fetches the tarball, checks its integrity and
395
+ * extracts the binary. A signed version other than the one asked, or as
334
396
  * `latest` one below `current`, is refused. Null when the release is
335
- * `current`, the version of the vx being replaced; `announce` hears the
336
- * version before the download starts.
397
+ * `current`, the version of the vx being replaced.
337
398
  */
338
- export async function releaseBinary(
399
+ async function signedRelease(
339
400
  pkg: string,
340
401
  spec: string,
341
- announce: (version: string) => void,
342
- key: SigningKey = NPM_SIGNING_KEY,
343
- current = VERSION,
344
- ): Promise<Uint8Array | null> {
402
+ key: SigningKey,
403
+ current: string,
404
+ silenceMs = SILENCE_MS,
405
+ ): Promise<{ version: string; download: () => Promise<Uint8Array> } | null> {
345
406
  const registry = resolveRegistry(pkg.split('/')[0]!)
346
- const release = verifiedRelease(await fetchManifest(pkg, spec, undefined, registry), pkg, key)
407
+ const release = verifiedRelease(
408
+ await fetchManifest(pkg, spec, undefined, registry, silenceMs),
409
+ pkg,
410
+ key,
411
+ )
347
412
  // npm signs every version it published: the signature proves the bytes,
348
413
  // not that they are the version asked for, so a mirror could hand back an
349
414
  // older one.
@@ -353,13 +418,48 @@ export async function releaseBinary(
353
418
  )
354
419
  }
355
420
  if (release.version === current) return null
356
- if (spec === 'latest' && Bun.semver.order(release.version, current) < 0) {
357
- throw new UserError(
358
- `vx upgrade: the registry's latest ${pkg} is ${release.version}, below this vx's ${current} — nothing replaced; to go back, ask for it: vx upgrade v${release.version}`,
359
- )
421
+ if (spec === 'latest') refuseOlderLatest(release.version, current, pkg)
422
+ return {
423
+ version: release.version,
424
+ download: async () => binaryFromTarball(await downloadVerified(release, registry, silenceMs)),
360
425
  }
426
+ }
427
+
428
+ /**
429
+ * The binary of `pkg@spec`, verified: the registry document's signature,
430
+ * then the tarball's integrity, then the extraction, each refusing before
431
+ * the next step runs. A signed version other than the one asked, or as
432
+ * `latest` one below `current`, is refused. Null when the release is
433
+ * `current`, the version of the vx being replaced; `announce` hears the
434
+ * version before the download starts. `silenceMs` bounds each wait on the
435
+ * registry (`SILENCE_MS`).
436
+ */
437
+ export async function releaseBinary(
438
+ pkg: string,
439
+ spec: string,
440
+ announce: (version: string) => void,
441
+ key: SigningKey = NPM_SIGNING_KEY,
442
+ current = VERSION,
443
+ silenceMs = SILENCE_MS,
444
+ ): Promise<Uint8Array | null> {
445
+ const release = await signedRelease(pkg, spec, key, current, silenceMs)
446
+ if (release === null) return null
361
447
  announce(release.version)
362
- return binaryFromTarball(await downloadVerified(release, registry))
448
+ return await release.download()
449
+ }
450
+
451
+ /**
452
+ * Refuses `latest` below `current`, the vx it would replace: a mirror
453
+ * behind, or a release pulled back, is no upgrade. One rule for every
454
+ * install kind — a binary, a package manager's, the global a run moves
455
+ * too; a package-manager install went down to it with exit 0. A version
456
+ * asked for by name goes back on purpose and is never refused here.
457
+ */
458
+ function refuseOlderLatest(latest: string, current: string, pkg = PACKAGE): void {
459
+ if (Bun.semver.order(latest, current) >= 0) return
460
+ throw new UserError(
461
+ `vx upgrade: the registry's latest ${pkg} is ${latest}, below this vx's ${current} — nothing replaced; to go back, ask for it: vx upgrade v${latest}`,
462
+ )
363
463
  }
364
464
 
365
465
  /**
@@ -661,14 +761,17 @@ export async function targetVersion(tag: string | undefined, verb = 'vx upgrade'
661
761
  /**
662
762
  * Run the package manager that owns a vx at `current` to bring it to
663
763
  * `version`; vx never installs one. `step` names the install when a run
664
- * moves two (`project `, `global `).
764
+ * moves two (`project `, `global `). `latest`: `version` is the
765
+ * registry's latest, not one the user named (`refuseOlderLatest`).
665
766
  */
666
767
  export async function upgradeManaged(
667
768
  install: ManagedInstall,
668
769
  version: string,
669
770
  current = VERSION,
670
771
  step = '',
772
+ latest = false,
671
773
  ): Promise<boolean> {
774
+ if (latest) refuseOlderLatest(version, current)
672
775
  const plugins = install.plugins ?? []
673
776
  if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
674
777
  ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
@@ -713,32 +816,42 @@ export async function upgradeManaged(
713
816
 
714
817
  /**
715
818
  * Replace the binary at `dest`, at `current`, with the verified release
716
- * `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
819
+ * `spec` names (`latest` or a version). `step` and `latest` as in
820
+ * `upgradeManaged`.
717
821
  */
718
- async function upgradeBinary(
822
+ export async function upgradeBinary(
719
823
  dest: string,
720
824
  spec: string,
721
825
  current = VERSION,
722
826
  step = '',
723
827
  key: SigningKey = NPM_SIGNING_KEY,
828
+ latest = false,
724
829
  ): Promise<boolean> {
725
- const pkg = platformPackage()
830
+ // The version it already is needs no registry: it fetched and verified
831
+ // the release before saying so.
832
+ if (spec === current) {
833
+ ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
834
+ return false
835
+ }
836
+ if (latest) refuseOlderLatest(spec, current)
837
+ // The registry document first, read-only: a binary already at `latest`
838
+ // in a directory this user cannot write was told to sudo, exit 1.
839
+ const release = await signedRelease(platformPackage(), spec, key, current)
840
+ if (release === null) {
841
+ ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
842
+ return false
843
+ }
726
844
  let installed: string | undefined
727
- const replaced = await replaceBinary(
845
+ await replaceBinary(
728
846
  dest,
729
- () =>
730
- releaseBinary(
731
- pkg,
732
- spec,
733
- (version) =>
734
- ui.print(
735
- 'out',
736
- ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
737
- ui.text(ui.dim(dest)),
738
- ),
739
- key,
740
- current,
741
- ),
847
+ () => {
848
+ ui.print(
849
+ 'out',
850
+ ui.status('info', 'vx upgrade', `${step}${current} → ${release.version}`),
851
+ ui.text(ui.dim(dest)),
852
+ )
853
+ return release.download()
854
+ },
742
855
  // The replaced binary's own version: the new build speaks for itself
743
856
  // rather than this process guessing, and one that cannot is rolled back.
744
857
  (bin) => {
@@ -746,13 +859,8 @@ async function upgradeBinary(
746
859
  return installed !== undefined
747
860
  },
748
861
  )
749
- ui.print(
750
- 'out',
751
- replaced
752
- ? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
753
- : ui.status('info', 'vx upgrade', `${step}already at ${current}`),
754
- )
755
- return replaced
862
+ ui.print('out', ui.status('ok', 'vx upgrade', `${step}installed ${installed}`))
863
+ return true
756
864
  }
757
865
 
758
866
  /** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
@@ -770,6 +878,7 @@ export async function upgradeGlobal(
770
878
  from: string,
771
879
  version: string,
772
880
  key: SigningKey = NPM_SIGNING_KEY,
881
+ latest = false,
773
882
  ): Promise<void> {
774
883
  await named(`global vx ${from}`, async () => {
775
884
  const current = answeredVersion(from)
@@ -781,8 +890,8 @@ export async function upgradeGlobal(
781
890
  return
782
891
  }
783
892
  const install = managedInstall(from, path.dirname(from))
784
- if (install === null) await upgradeBinary(from, version, current, 'global ', key)
785
- else await upgradeManaged(install, version, current, 'global ')
893
+ if (install === null) await upgradeBinary(from, version, current, 'global ', key, latest)
894
+ else await upgradeManaged(install, version, current, 'global ', latest)
786
895
  })
787
896
  }
788
897
 
@@ -795,20 +904,22 @@ export async function upgradeOnlyGlobal(
795
904
  self: string,
796
905
  version: string,
797
906
  current = VERSION,
907
+ latest = false,
798
908
  ): Promise<void> {
799
- if (current === version) {
800
- ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
801
- return
802
- }
803
909
  // Read from the binary's own directory, not cwd: a project there is not this install's.
804
910
  const install = managedInstall(self, path.dirname(self))
911
+ // Before the version check: at the version, the project's own vx was called the global.
805
912
  if (install?.scope === 'project') {
806
913
  throw new UserError(
807
914
  `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (bun add -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
808
915
  )
809
916
  }
810
- if (install === null) await upgradeBinary(self, version, current, 'global ')
811
- else await upgradeManaged(install, version, current, 'global ')
917
+ if (current === version) {
918
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
919
+ return
920
+ }
921
+ if (install === null) await upgradeBinary(self, version, current, 'global ', undefined, latest)
922
+ else await upgradeManaged(install, version, current, 'global ', latest)
812
923
  }
813
924
 
814
925
  /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
@@ -865,36 +976,56 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
865
976
  }
866
977
  const dest = process.execPath
867
978
  const handedOffFrom = process.env['VX_HANDED_OFF'] || undefined
979
+ const latest = tag === undefined
868
980
  if (global) {
869
981
  // A launcher from before -g handed the call over: the global is the one that did.
870
982
  const version = await targetVersion(tag)
871
- if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
872
- else await upgradeOnlyGlobal(dest, version)
983
+ if (handedOffFrom !== undefined)
984
+ await upgradeGlobal(handedOffFrom, version, NPM_SIGNING_KEY, latest)
985
+ else await upgradeOnlyGlobal(dest, version, VERSION, latest)
873
986
  return 0
874
987
  }
875
988
  const install = managedInstall(dest, process.cwd())
876
989
  // A global vx handed this run to the project's own: both move, to one
877
990
  // version, the project first (2026-10-15).
878
- const from = handedOffFrom
879
- if (from !== undefined) {
880
- const version = await targetVersion(tag)
881
- const moved = await named('project vx', () =>
882
- install === null
883
- ? upgradeBinary(dest, version, VERSION, 'project ')
884
- : upgradeManaged(install, version, VERSION, 'project '),
885
- )
886
- await upgradeGlobal(from, version)
887
- if (moved) await relock(newVx(install, dest))
991
+ if (handedOffFrom !== undefined) {
992
+ await upgradeBoth(handedOffFrom, dest, install, await targetVersion(tag), latest)
888
993
  return 0
889
994
  }
890
995
  const moved =
891
996
  install !== null
892
- ? await upgradeManaged(install, await targetVersion(tag))
997
+ ? await upgradeManaged(install, await targetVersion(tag), VERSION, '', latest)
893
998
  : await upgradeBinary(dest, versionSpec(tag))
894
999
  if (moved) await relock(newVx(install, dest))
895
1000
  return 0
896
1001
  }
897
1002
 
1003
+ /**
1004
+ * Move the project's vx at `dest` (`install`, null for a binary), then the
1005
+ * global `from` that handed the run to it, to `version`. The project's
1006
+ * move is locked even when the global's then fails: the lock was skipped.
1007
+ */
1008
+ export async function upgradeBoth(
1009
+ from: string,
1010
+ dest: string,
1011
+ install: ManagedInstall | null,
1012
+ version: string,
1013
+ latest: boolean,
1014
+ key: SigningKey = NPM_SIGNING_KEY,
1015
+ current = VERSION,
1016
+ ): Promise<void> {
1017
+ const moved = await named('project vx', () =>
1018
+ install === null
1019
+ ? upgradeBinary(dest, version, current, 'project ', key, latest)
1020
+ : upgradeManaged(install, version, current, 'project ', latest),
1021
+ )
1022
+ try {
1023
+ await upgradeGlobal(from, version, key, latest)
1024
+ } finally {
1025
+ if (moved) await relock(newVx(install, dest))
1026
+ }
1027
+ }
1028
+
898
1029
  /**
899
1030
  * The vx an upgrade just installed: the replaced binary, or the tree's
900
1031
  * `node_modules/.bin/vx` (a manager's store path names the old version).
@@ -938,6 +1069,9 @@ export function startedVersion(bin: string, timeoutMs = 10_000): string | null {
938
1069
  try {
939
1070
  proc = Bun.spawnSync({
940
1071
  cmd: [bin, '--version'],
1072
+ // Not in a workspace: there a global vx hands `--version` to the
1073
+ // project's and answers its version, not its own.
1074
+ cwd: '/',
941
1075
  stdin: 'ignore',
942
1076
  stdout: 'pipe',
943
1077
  stderr: 'pipe',
@@ -6,6 +6,7 @@ import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import {
8
8
  executablePath,
9
+ gitCaseArgs,
9
10
  gitEnv,
10
11
  isLiteralPattern,
11
12
  normalizeGlob,
@@ -15,7 +16,12 @@ import {
15
16
  taskGlob,
16
17
  } from '../util/index.js'
17
18
  import { asTrees } from '../cache/index.js'
18
- import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
19
+ import {
20
+ DEPENDENCIES_LOCK_NAME,
21
+ parseDependenciesLock,
22
+ PROJECT_CONFIG_FILENAMES,
23
+ WORKSPACE_CONFIG_FILENAMES,
24
+ } from '../workspace/index.js'
19
25
  import { isIgnoredDir } from './watch-fs.js'
20
26
 
21
27
  const IGNORED_SUFFIXES = ['~']
@@ -27,19 +33,31 @@ const IGNORED_SUFFIXES = ['~']
27
33
  * writes to `.vx/cache` again.
28
34
  *
29
35
  * Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
30
- * in the path, not only at the root.
36
+ * in the path, not only at the root. `makeWatchIgnore` keeps a path there
37
+ * that an input names or git tracks.
31
38
  */
32
39
  export function isIgnoredWatchPath(rel: string): boolean {
40
+ return ignoredBy(rel) !== undefined
41
+ }
42
+
43
+ /** Why `isIgnoredWatchPath` drops `rel`: `'node_modules'` when only that segment does. */
44
+ function ignoredBy(rel: string): 'always' | 'node_modules' | undefined {
33
45
  const segments = rel.split(path.sep)
34
- for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
35
- if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
46
+ let why: 'node_modules' | undefined
47
+ for (let i = 1; i <= segments.length; i++) {
48
+ if (!isIgnoredDir(segments.slice(0, i))) continue
49
+ if (segments[i - 1] !== 'node_modules') return 'always'
50
+ why = 'node_modules'
51
+ }
52
+ if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return 'always'
36
53
  const last = segments.length - 1
37
54
  // The cache's first save writes `.vx/.gitignore`, which ignores only the
38
55
  // `.vx/cache` and `.vx/tmp` already skipped: a watched root project ran
39
56
  // one more cycle for it.
40
- if (segments[last] === '.gitignore' && segments[last - 1] === '.vx') return true
57
+ if (segments[last] === '.gitignore' && segments[last - 1] === '.vx') return 'always'
41
58
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
42
- return segments[last] === 'vx.lock.json'
59
+ if (segments[last] === 'vx.lock.json') return 'always'
60
+ return why
43
61
  }
44
62
 
45
63
  /**
@@ -152,6 +170,44 @@ export function makeWatchIgnore(
152
170
  }
153
171
  return trackedDirs.has(abs)
154
172
  }
173
+ // Under `node_modules`, only what an input names through that segment
174
+ // (`node_modules/.prisma/client/**`): a broad glob (`**`, `src/**` over
175
+ // `src/node_modules`) never reached it, and walking every package to drop
176
+ // its events is the cost the skip spares.
177
+ const namesModules = (abs: string, dir: boolean): boolean =>
178
+ read.some(([base, globs, not, containers, reaches, trees]) => {
179
+ if (!abs.startsWith(base + path.sep)) return false
180
+ const rel = abs
181
+ .slice(base.length + 1)
182
+ .split(path.sep)
183
+ .join('/')
184
+ const segments = rel.split('/')
185
+ const through = segments.slice(0, segments.lastIndexOf('node_modules') + 1).join('/')
186
+ if (dir && trees.some((t) => rel === t || rel.startsWith(`${t}/`))) return false
187
+ return reaches.some(([p, max], i) => {
188
+ if (p !== through && !p.startsWith(`${through}/`)) return false
189
+ if (!dir)
190
+ return (
191
+ globs[i]!.match(rel) &&
192
+ !not.some((g) => g.match(rel)) &&
193
+ !containers.some((c) => c === rel || c.startsWith(`${rel}/`))
194
+ )
195
+ return (
196
+ segments.length < max && (p === rel || p.startsWith(`${rel}/`) || rel.startsWith(`${p}/`))
197
+ )
198
+ })
199
+ })
200
+ // Core never cleans or saves a file git tracks and keys it under the
201
+ // task's own output glob (2026-10-14); a submodule is listed as its
202
+ // gitlink alone, so a path under a tracked one counts.
203
+ const isTracked = (abs: string): boolean => {
204
+ if (tracked === undefined) return false
205
+ for (let p = abs; p.length > 1; p = path.dirname(p)) {
206
+ if (tracked.has(p)) return true
207
+ if (p === path.dirname(p)) break
208
+ }
209
+ return false
210
+ }
155
211
  const isInput = (abs: string): boolean =>
156
212
  read.some(([dir, globs, not, containers]) => {
157
213
  if (!abs.startsWith(dir + path.sep)) return false
@@ -164,19 +220,15 @@ export function makeWatchIgnore(
164
220
  return !containers.some((c) => c === rel || c.startsWith(`${rel}/`))
165
221
  })
166
222
  return (base, filename, dir = false) => {
167
- if (isIgnoredWatchPath(filename)) return true
223
+ const why = ignoredBy(filename)
224
+ if (why === 'always') return true
168
225
  const abs = path.resolve(base, filename)
169
226
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
227
+ if (why === 'node_modules')
228
+ return !(namesModules(abs, dir) || (dir ? holdsTracked(abs) : isTracked(abs)))
170
229
  if (isInput(abs)) return false
171
230
  if (dir && (mayHoldInput(abs) || holdsTracked(abs))) return false
172
- // Core never cleans or saves a file git tracks and keys it under the
173
- // task's own output glob (2026-10-14); a submodule is listed as its
174
- // gitlink alone, so a path under a tracked one counts.
175
- if (tracked !== undefined)
176
- for (let p = abs; p.length > 1; p = path.dirname(p)) {
177
- if (tracked.has(p)) return false
178
- if (p === path.dirname(p)) break
179
- }
231
+ if (isTracked(abs)) return false
180
232
  for (const [dir, isOutput, containers] of declared) {
181
233
  if (!abs.startsWith(dir + path.sep)) continue
182
234
  const rel = abs
@@ -398,6 +450,26 @@ export function isWorkspaceConfigFile(name: string): boolean {
398
450
  return WORKSPACE_CONFIGS.has(name)
399
451
  }
400
452
 
453
+ /**
454
+ * The files the claimed files' recorded digests read besides them
455
+ * (`FingerprintDigests.extras`: a `file:` dependency's directory, a patch),
456
+ * absolute, from `.vx/dependencies.lock.json` as `vx lock` wrote it: a run
457
+ * keys them from there. Empty when there is no readable lock.
458
+ */
459
+ export function lockedExtras(workspaceRoot: string): Set<string> {
460
+ let bytes: Uint8Array | null = null
461
+ try {
462
+ bytes = fs.readFileSync(path.join(workspaceRoot, DEPENDENCIES_LOCK_NAME))
463
+ } catch {
464
+ // No lock: no record, and a run refuses a claimant that asks for one.
465
+ }
466
+ const lock = parseDependenciesLock(bytes)
467
+ const out = new Set<string>()
468
+ for (const rec of Object.values(lock?.files ?? {}))
469
+ for (const rel of Object.keys(rec.extras ?? {})) out.add(path.resolve(workspaceRoot, rel))
470
+ return out
471
+ }
472
+
401
473
  /**
402
474
  * Paths as git spells them under `root`: a project reached through a
403
475
  * symlink below the root (`packages/x -> ../shared/x`) is watched at the
@@ -446,6 +518,7 @@ export function gitFiles(
446
518
  proc = Bun.spawnSync({
447
519
  cmd: [
448
520
  executablePath('git'),
521
+ ...gitCaseArgs(workspaceRoot),
449
522
  'ls-files',
450
523
  '-z',
451
524
  '-t',