@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -1,6 +1,7 @@
1
1
  // `vx upgrade [TAG]` — bring this vx to a newer release, the way it was
2
2
  // installed. A compiled binary installed by hand replaces itself: it reads
3
- // its platform package (`@vzn/vx-<os>-<arch>`) from the npm registry,
3
+ // its platform package (`@vzn/vx-<os>-<arch>`) from the npm registry the
4
+ // user configured (`resolveRegistry`: .npmrc, bunfig, npmjs.org last),
4
5
  // checks npm's registry signature over `name@version:integrity` against
5
6
  // the key pinned below, checks the tarball's SHA-512 against that signed
6
7
  // integrity, and only then extracts the binary, stages it beside the old
@@ -19,12 +20,12 @@ import { constants, existsSync, readFileSync } from 'node:fs'
19
20
  import { createPublicKey, verify } from 'node:crypto'
20
21
  import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
21
22
  import path from 'node:path'
22
- import { flagHint, seeHelp } from './help.js'
23
+ import { flagHint, refuse, seeHelp } from './help.js'
24
+ import { resolveRegistry, type Registry } from './registry.js'
23
25
  import { UserError, sayNotice, ui } from '../util/index.js'
24
26
  import { findWorkspaceRoot } from '../workspace/index.js'
25
27
  import { VERSION } from '../version.js'
26
28
 
27
- const REGISTRY = 'https://registry.npmjs.org'
28
29
  const PACKAGE = '@vzn/vx'
29
30
 
30
31
  /**
@@ -68,7 +69,6 @@ export function isCompiledBinary(): boolean {
68
69
  }
69
70
 
70
71
  /**
71
- * The package dir/**
72
72
  * The package directory that owns `execPath` when it sits under a
73
73
  * `node_modules` — an npm install's platform binary, which the launcher
74
74
  * runs as `…/node_modules/@vzn/vx-<os>-<arch>/vx`. That file is npm's:
@@ -101,7 +101,10 @@ export function versionSpec(tag: string | undefined): string {
101
101
  if (tag === undefined) return 'latest'
102
102
  const v = tag.startsWith('v') ? tag.slice(1) : tag
103
103
  if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
104
- throw new UserError(`vx upgrade: ${tag} is not a version (expected v<major>.<minor>.<patch>)`)
104
+ throw new UserError(
105
+ `vx upgrade: ${tag} is not a version (expected v<major>.<minor>.<patch>)`,
106
+ 'VX_E_USAGE',
107
+ )
105
108
  }
106
109
  return v
107
110
  }
@@ -157,17 +160,25 @@ export async function fetchManifest(
157
160
  pkg: string,
158
161
  spec: string,
159
162
  verb = 'vx upgrade',
163
+ registry: Registry = resolveRegistry(pkg.split('/')[0]!),
160
164
  ): Promise<unknown> {
161
- const url = `${REGISTRY}/${pkg}/${spec}`
165
+ const url = `${registry.url}${pkg}/${spec}`
162
166
  const res = await fetchOrRefuse(
163
167
  url,
164
- { redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
168
+ {
169
+ redirect: 'follow',
170
+ headers: {
171
+ Accept: 'application/json',
172
+ 'User-Agent': `vx/${VERSION}`,
173
+ ...authHeader(registry, url),
174
+ },
175
+ },
165
176
  'read the release',
166
177
  verb,
167
178
  )
168
179
  if (!res.ok) {
169
180
  throw new UserError(
170
- `${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
181
+ `${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' : ''}`,
171
182
  )
172
183
  }
173
184
  // A body that is not JSON (a captive portal's page, a proxy's error
@@ -175,6 +186,11 @@ export async function fetchManifest(
175
186
  return await readOrRefuse(() => res.json(), url, 'read the release', verb)
176
187
  }
177
188
 
189
+ function authHeader(registry: Registry, url: string): Record<string, string> {
190
+ const auth = registry.auth(url)
191
+ return auth === undefined ? {} : { Authorization: auth }
192
+ }
193
+
178
194
  /** What a verified registry document names: the version, its tarball and the signed integrity. */
179
195
  export interface SignedRelease {
180
196
  version: string
@@ -228,7 +244,7 @@ export function verifiedRelease(
228
244
  const ours = signatures.filter((s) => s.keyid === key.id)
229
245
  if (ours.length === 0) {
230
246
  throw new UserError(
231
- `vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with npm install -g @vzn/vx@latest, or from https://github.com/vznjs/vx/releases. Nothing replaced`,
247
+ `vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with bun add -g @vzn/vx@latest, or from https://github.com/vznjs/vx/releases. Nothing replaced`,
232
248
  )
233
249
  }
234
250
  const publicKey = createPublicKey({
@@ -253,9 +269,13 @@ export function verifiedRelease(
253
269
  }
254
270
 
255
271
  /** Download `release.tarball` and check its bytes against the signed SHA-512. */
256
- async function downloadVerified(release: SignedRelease): Promise<Uint8Array> {
272
+ async function downloadVerified(release: SignedRelease, registry: Registry): Promise<Uint8Array> {
257
273
  const url = release.tarball
258
- const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release')
274
+ const res = await fetchOrRefuse(
275
+ url,
276
+ { redirect: 'follow', headers: authHeader(registry, url) },
277
+ 'download the release',
278
+ )
259
279
  if (!res.ok) {
260
280
  throw new UserError(
261
281
  `vx upgrade: download failed (${res.status}) — ${url} — nothing replaced; re-run`,
@@ -308,9 +328,10 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
308
328
  /**
309
329
  * The binary of `pkg@spec`, verified: the registry document's signature,
310
330
  * then the tarball's integrity, then the extraction, each refusing before
311
- * the next step runs. Null when the release is `current`, the version of
312
- * the vx being replaced; `announce` hears the version before the download
313
- * starts.
331
+ * the next step runs. A signed version other than the one asked, or as
332
+ * `latest` one below `current`, is refused. Null when the release is
333
+ * `current`, the version of the vx being replaced; `announce` hears the
334
+ * version before the download starts.
314
335
  */
315
336
  export async function releaseBinary(
316
337
  pkg: string,
@@ -319,10 +340,24 @@ export async function releaseBinary(
319
340
  key: SigningKey = NPM_SIGNING_KEY,
320
341
  current = VERSION,
321
342
  ): Promise<Uint8Array | null> {
322
- const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
343
+ const registry = resolveRegistry(pkg.split('/')[0]!)
344
+ const release = verifiedRelease(await fetchManifest(pkg, spec, undefined, registry), pkg, key)
345
+ // npm signs every version it published: the signature proves the bytes,
346
+ // not that they are the version asked for, so a mirror could hand back an
347
+ // older one.
348
+ if (spec !== 'latest' && release.version !== spec) {
349
+ throw new UserError(
350
+ `vx upgrade: the npm registry answered ${pkg}@${spec} with version ${release.version} — refusing to install; nothing replaced`,
351
+ )
352
+ }
323
353
  if (release.version === current) return null
354
+ if (spec === 'latest' && Bun.semver.order(release.version, current) < 0) {
355
+ throw new UserError(
356
+ `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}`,
357
+ )
358
+ }
324
359
  announce(release.version)
325
- return binaryFromTarball(await downloadVerified(release))
360
+ return binaryFromTarball(await downloadVerified(release, registry))
326
361
  }
327
362
 
328
363
  /**
@@ -417,12 +452,34 @@ export interface ManagedInstall {
417
452
 
418
453
  const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
419
454
 
455
+ /** `file` as a JSON object, or null where there is none; one that is not JSON is refused. */
420
456
  function readJson(file: string): Record<string, unknown> | null {
457
+ let text: string
458
+ try {
459
+ text = readFileSync(file, 'utf8')
460
+ } catch (err) {
461
+ const code = (err as NodeJS.ErrnoException).code
462
+ if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') return null
463
+ throw new UserError(`vx upgrade: could not read ${file} (${(err as Error).message})`)
464
+ }
465
+ let v: unknown
466
+ try {
467
+ // A byte-order mark is stripped, as npm strips it.
468
+ v = JSON.parse(text.replace(/^\uFEFF/, ''))
469
+ } catch (err) {
470
+ // Read as absent, it sent the user to "no package.json lists vx".
471
+ throw new UserError(
472
+ `vx upgrade: ${file} is not JSON (${(err as Error).message}) — fix it, then run vx upgrade again`,
473
+ )
474
+ }
475
+ return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
476
+ }
477
+
478
+ function lockText(file: string): string {
421
479
  try {
422
- const v: unknown = JSON.parse(readFileSync(file, 'utf8'))
423
- return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
480
+ return readFileSync(file, 'utf8')
424
481
  } catch {
425
- return null
482
+ return ''
426
483
  }
427
484
  }
428
485
 
@@ -441,8 +498,8 @@ const PLATFORM = /^@vzn\/vx-(?:darwin|linux)-(?:x64|arm64)$/
441
498
  /**
442
499
  * The `@vzn/vx-*` plugins `dir`'s package.json lists from the registry
443
500
  * (a `workspace:` or `file:` link is the repo's own). Every @vzn package
444
- * releases together, so they move with core in its one install (M,
445
- * 2026-10-16); one in another field would move fields under core's flag,
501
+ * releases together, so they move with core in its one install
502
+ * (2026-10-16); one in another field would move fields under core's flag,
446
503
  * so it is refused.
447
504
  */
448
505
  function pluginsBeside(dir: string, field: NonNullable<ManagedInstall['field']>): string[] {
@@ -511,7 +568,7 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
511
568
  const root = p.slice(0, `${p}/`.indexOf('/node_modules/'))
512
569
  if (p.includes('/_npx/') || /\/bunx-[^/]*\//.test(p)) {
513
570
  throw new UserError(
514
- `vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead: npx @vzn/vx@latest, or bunx @vzn/vx@latest`,
571
+ `vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead: bunx @vzn/vx@latest`,
515
572
  )
516
573
  }
517
574
  if (p.includes('/.bun/install/global/')) return { manager: 'bun', scope: 'global', cwd: root }
@@ -523,7 +580,11 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
523
580
  const plugins = pluginsBeside(root, field)
524
581
  return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
525
582
  }
526
- for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
583
+ for (
584
+ let d = path.resolve(cwd);
585
+ d === root || d.startsWith(`${root}${path.sep}`);
586
+ d = path.dirname(d)
587
+ ) {
527
588
  const f = listingField(d)
528
589
  if (f !== undefined) {
529
590
  const plugins = pluginsBeside(d, f)
@@ -569,6 +630,14 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
569
630
  // pnpm refuses an `add` at a workspace's root without saying it means the root.
570
631
  if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
571
632
  argv.push('--workspace-root')
633
+ // Yarn 1 refuses one there without -W; Berry, whose lockfile carries
634
+ // `__metadata`, has no such flag.
635
+ if (
636
+ manager === 'yarn' &&
637
+ readJson(path.join(install.cwd, 'package.json'))?.['workspaces'] !== undefined &&
638
+ !lockText(path.join(install.cwd, 'yarn.lock')).includes('__metadata:')
639
+ )
640
+ argv.push('-W')
572
641
  return argv
573
642
  }
574
643
 
@@ -717,7 +786,7 @@ export async function upgradeGlobal(
717
786
 
718
787
  /**
719
788
  * `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
720
- * else (owner, 2026-10-16) — no project's package.json, no plugin. The
789
+ * else (2026-10-16) — no project's package.json, no plugin. The
721
790
  * project's own vx refuses: no global ran.
722
791
  */
723
792
  export async function upgradeOnlyGlobal(
@@ -733,7 +802,7 @@ export async function upgradeOnlyGlobal(
733
802
  const install = managedInstall(self, path.dirname(self))
734
803
  if (install?.scope === 'project') {
735
804
  throw new UserError(
736
- `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
805
+ `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`,
737
806
  )
738
807
  }
739
808
  if (install === null) await upgradeBinary(self, version, current, 'global ')
@@ -750,33 +819,50 @@ async function named<T>(what: string, run: () => Promise<T>): Promise<T> {
750
819
  }
751
820
  }
752
821
 
753
- export async function upgradeCmd(argv: readonly string[]): Promise<number> {
822
+ interface UpgradeArgs {
823
+ global: boolean
824
+ tag?: string
825
+ error?: string
826
+ /** The error names an unknown flag: `--format json` reads it as JSON. */
827
+ flag?: true
828
+ }
829
+
830
+ export function parseUpgradeArgs(argv: readonly string[]): UpgradeArgs {
754
831
  const global = argv.some((a) => a === '-g' || a === '--global')
755
832
  const args = argv.filter((a) => a !== '-g' && a !== '--global')
756
833
  const unknown = args.find((a) => a.startsWith('-'))
757
834
  if (unknown !== undefined) {
758
- sayNotice(
759
- 'error',
760
- 'vx upgrade',
761
- `unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
762
- )
763
- return 1
835
+ return {
836
+ global,
837
+ flag: true,
838
+ error: `unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
839
+ }
764
840
  }
765
841
  // One release is installed; a second tag was dropped without a word and
766
842
  // the first one went in (`vx upgrade v0.0.22 v0.0.23`).
767
843
  const [tag, extra] = args
768
844
  if (extra !== undefined) {
769
- sayNotice('error', 'vx upgrade', `unexpected argument: ${extra}${seeHelp('upgrade')}`)
845
+ return { global, error: `unexpected argument: ${extra}${seeHelp('upgrade')}` }
846
+ }
847
+ return tag === undefined ? { global } : { global, tag }
848
+ }
849
+
850
+ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
851
+ const { global, tag, error, flag } = parseUpgradeArgs(argv)
852
+ if (error !== undefined && flag === true) return refuse('upgrade', argv, error, 'VX_E_USAGE')
853
+ if (error !== undefined) {
854
+ sayNotice('error', 'vx upgrade', error)
770
855
  return 1
771
856
  }
857
+ // A mistyped tag is the mistake to name, before any guard about this install.
858
+ versionSpec(tag)
772
859
  if (!isCompiledBinary()) {
773
860
  throw new UserError(
774
- 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull ' +
775
- '(npm installs update with: npm install -g @vzn/vx@latest)',
861
+ 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull',
776
862
  )
777
863
  }
778
864
  const dest = process.execPath
779
- const handedOffFrom = process.env['VX_HANDED_OFF']
865
+ const handedOffFrom = process.env['VX_HANDED_OFF'] || undefined
780
866
  if (global) {
781
867
  // A launcher from before -g handed the call over: the global is the one that did.
782
868
  const version = await targetVersion(tag)
@@ -786,7 +872,7 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
786
872
  }
787
873
  const install = managedInstall(dest, process.cwd())
788
874
  // A global vx handed this run to the project's own: both move, to one
789
- // version, the project first (owner, 2026-10-15).
875
+ // version, the project first (2026-10-15).
790
876
  const from = handedOffFrom
791
877
  if (from !== undefined) {
792
878
  const version = await targetVersion(tag)
@@ -808,26 +894,35 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
808
894
  }
809
895
 
810
896
  /**
811
- * The vx an upgrade just installed: the replaced binary, or the project's
897
+ * The vx an upgrade just installed: the replaced binary, or the tree's
812
898
  * `node_modules/.bin/vx` (a manager's store path names the old version).
899
+ * npm's global tree has no `.bin` there; its platform binary keeps its path.
813
900
  */
814
- const newVx = (install: ManagedInstall | null, dest: string): string =>
815
- install === null ? dest : path.join(install.cwd, 'node_modules', '.bin', 'vx')
901
+ function newVx(install: ManagedInstall | null, dest: string): string {
902
+ if (install === null) return dest
903
+ const bin = path.join(install.cwd, 'node_modules', '.bin', 'vx')
904
+ return existsSync(bin) ? bin : dest
905
+ }
816
906
 
817
907
  /**
818
- * Lock the workspace with the vx just installed at `bin` (M, 2026-10-18):
908
+ * Lock the workspace with the vx just installed at `bin` (2026-10-18):
819
909
  * a release may record the lock differently. `-g` never re-locks. A lock
820
910
  * that fails leaves the upgrade done and says what to run.
821
911
  */
822
912
  export async function relock(bin: string, cwd = process.cwd()): Promise<void> {
823
913
  const root = await findWorkspaceRoot(cwd).catch(() => null)
824
914
  if (root === null) return
825
- const proc = Bun.spawnSync({
826
- cmd: [bin, 'lock'],
827
- cwd: root,
828
- stdio: ['inherit', 'inherit', 'inherit'],
829
- })
830
- if (proc.exitCode !== 0) sayNotice('warn', 'vx upgrade', 'vx lock failed: run vx lock')
915
+ let code: number | null
916
+ try {
917
+ code = Bun.spawnSync({
918
+ cmd: [bin, 'lock'],
919
+ cwd: root,
920
+ stdio: ['inherit', 'inherit', 'inherit'],
921
+ }).exitCode
922
+ } catch {
923
+ code = null
924
+ }
925
+ if (code !== 0) sayNotice('warn', 'vx upgrade', 'vx lock failed: run vx lock')
831
926
  }
832
927
 
833
928
  /**
@@ -837,13 +932,19 @@ export async function relock(bin: string, cwd = process.cwd()): Promise<void> {
837
932
  * binary in place, old one never restored.
838
933
  */
839
934
  export function startedVersion(bin: string, timeoutMs = 10_000): string | null {
840
- const proc = Bun.spawnSync({
841
- cmd: [bin, '--version'],
842
- stdin: 'ignore',
843
- stdout: 'pipe',
844
- stderr: 'pipe',
845
- timeout: timeoutMs,
846
- })
935
+ let proc: ReturnType<typeof Bun.spawnSync>
936
+ try {
937
+ proc = Bun.spawnSync({
938
+ cmd: [bin, '--version'],
939
+ stdin: 'ignore',
940
+ stdout: 'pipe',
941
+ stderr: 'pipe',
942
+ timeout: timeoutMs,
943
+ })
944
+ } catch {
945
+ // The OS refused the spawn (EACCES on a noexec mount, ENOENT): it did not start.
946
+ return null
947
+ }
847
948
  const out = new TextDecoder().decode(proc.stdout).trim()
848
949
  return proc.exitCode === 0 && out.startsWith('vx ') ? out : null
849
950
  }
@@ -5,7 +5,7 @@
5
5
  // as Ctrl-C would and the loop starts the next one; a cycle running any
6
6
  // other work is never stopped.
7
7
 
8
- import { type RunOptions } from '../orchestrator/index.js'
8
+ import { CYCLE_INTERRUPT, type RunOptions } from '../orchestrator/index.js'
9
9
  import { createEventBus } from '../report/index.js'
10
10
 
11
11
  export interface WatchCycle {
@@ -13,7 +13,9 @@ export interface WatchCycle {
13
13
  readonly opts: RunOptions
14
14
  /** Every task in flight is a persistent one not yet ready. */
15
15
  waitsOnReadiness(): boolean
16
- /** Stop the run as a SIGTERM would; watch keeps going. */
16
+ /** The project directories of the servers in flight. */
17
+ serverDirs(): string[]
18
+ /** Stop the run's own work as a SIGTERM would, sparing the servers it was handed; watch keeps going. */
17
19
  interrupt(): void
18
20
  readonly interrupted: boolean
19
21
  /** The run's task count, once it has started. */
@@ -26,21 +28,24 @@ export function watchCycle(opts: RunOptions, stop: AbortSignal, onWaiting: () =>
26
28
  const bus = createEventBus()
27
29
  // id → persistent; a group task has no process and holds nothing up.
28
30
  const active = new Map<string, boolean>()
31
+ const dirs = new Map<string, string>()
29
32
  let tasks: number | undefined
30
33
  const waitsOnReadiness = (): boolean =>
31
34
  active.size > 0 && [...active.values()].every((persistent) => persistent)
32
35
  bus.subscribe((e) => {
33
36
  if (e.kind === 'run:start') tasks = e.info.total
34
- else if (e.kind === 'task:start' && e.node.config.exec !== undefined)
37
+ else if (e.kind === 'task:start' && e.node.config.exec !== undefined) {
35
38
  active.set(e.node.id, e.node.config.exec.persistent !== undefined)
36
- else if (e.kind === 'task:complete') active.delete(e.node.id)
39
+ dirs.set(e.node.id, e.node.projectDir)
40
+ } else if (e.kind === 'task:complete') active.delete(e.node.id)
37
41
  else return
38
42
  if (waitsOnReadiness()) onWaiting()
39
43
  })
40
44
  return {
41
45
  opts: { ...opts, bus, footer: false, signal: AbortSignal.any([stop, own.signal]) },
42
46
  waitsOnReadiness,
43
- interrupt: () => own.abort('SIGTERM'),
47
+ serverDirs: () => [...active].filter(([, server]) => server).map(([id]) => dirs.get(id)!),
48
+ interrupt: () => own.abort(CYCLE_INTERRUPT),
44
49
  get interrupted() {
45
50
  return own.signal.aborted
46
51
  },
@@ -33,8 +33,13 @@ export function isIgnoredWatchPath(rel: string): boolean {
33
33
  const segments = rel.split(path.sep)
34
34
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
35
35
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
36
+ const last = segments.length - 1
37
+ // The cache's first save writes `.vx/.gitignore`, which ignores only the
38
+ // `.vx/cache` and `.vx/tmp` already skipped: a watched root project ran
39
+ // one more cycle for it.
40
+ if (segments[last] === '.gitignore' && segments[last - 1] === '.vx') return true
36
41
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
37
- return segments[segments.length - 1] === 'vx.lock.json'
42
+ return segments[last] === 'vx.lock.json'
38
43
  }
39
44
 
40
45
  /**
@@ -55,7 +60,7 @@ export function makeWatchIgnore(
55
60
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
56
61
  /** What git tracked at the arm (`gitFiles`): never an output, so an edit there is one. */
57
62
  tracked?: ReadonlySet<string>,
58
- ): (base: string, filename: string) => boolean {
63
+ ): (base: string, filename: string, dir?: boolean) => boolean {
59
64
  // Watchers report under the real root; a cache dir named through a link
60
65
  // (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
61
66
  const cacheAbs = realThrough(path.resolve(cacheDir))
@@ -104,9 +109,49 @@ export function makeWatchIgnore(
104
109
  .filter((g) => g.startsWith('!'))
105
110
  .map((g) => outputContainer(g.slice(1)))
106
111
  .filter((c) => c !== ''),
112
+ asTrees(globs.filter((g) => !g.startsWith('!'))).map(reach),
113
+ ownTrees(globs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))),
107
114
  ] as const,
108
115
  ),
109
116
  )
117
+ // A directory the poller skips is one whose files it never samples: an
118
+ // output container that also holds inputs (tsc's `.js` beside `.ts`,
119
+ // `src/**/*.js` out of `src/**/*.ts`) is walked, or `--poll` never saw
120
+ // the edit the native watcher ran.
121
+ const mayHoldInput = (abs: string): boolean =>
122
+ read.some(([dir, , , , reaches, trees]) => {
123
+ if (abs !== dir && !abs.startsWith(dir + path.sep)) return false
124
+ const rel = abs
125
+ .slice(dir.length + 1)
126
+ .split(path.sep)
127
+ .join('/')
128
+ if (trees.some((t) => rel === t || rel.startsWith(`${t}/`))) return false
129
+ const depth = rel === '' ? 0 : rel.split('/').length
130
+ return reaches.some(
131
+ ([p, max]) =>
132
+ depth < max &&
133
+ (p === '' ||
134
+ rel === '' ||
135
+ p === rel ||
136
+ p.startsWith(`${rel}/`) ||
137
+ rel.startsWith(`${p}/`)),
138
+ )
139
+ })
140
+ let trackedDirs: Set<string> | undefined
141
+ const holdsTracked = (abs: string): boolean => {
142
+ if (tracked === undefined) return false
143
+ if (trackedDirs === undefined) {
144
+ trackedDirs = new Set()
145
+ for (const f of tracked)
146
+ for (
147
+ let p = path.dirname(f);
148
+ !trackedDirs.has(p) && p !== path.dirname(p);
149
+ p = path.dirname(p)
150
+ )
151
+ trackedDirs.add(p)
152
+ }
153
+ return trackedDirs.has(abs)
154
+ }
110
155
  const isInput = (abs: string): boolean =>
111
156
  read.some(([dir, globs, not, containers]) => {
112
157
  if (!abs.startsWith(dir + path.sep)) return false
@@ -118,11 +163,12 @@ export function makeWatchIgnore(
118
163
  if (not.some((g) => g.match(rel))) return false
119
164
  return !containers.some((c) => c === rel || c.startsWith(`${rel}/`))
120
165
  })
121
- return (base, filename) => {
166
+ return (base, filename, dir = false) => {
122
167
  if (isIgnoredWatchPath(filename)) return true
123
168
  const abs = path.resolve(base, filename)
124
169
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
125
170
  if (isInput(abs)) return false
171
+ if (dir && (mayHoldInput(abs) || holdsTracked(abs))) return false
126
172
  // Core never cleans or saves a file git tracks and keys it under the
127
173
  // task's own output glob (2026-10-14); a submodule is listed as its
128
174
  // gitlink alone, so a path under a tracked one counts.
@@ -144,6 +190,23 @@ export function makeWatchIgnore(
144
190
  }
145
191
  }
146
192
 
193
+ /**
194
+ * Where a glob's matches can lie: under its static prefix (`''` for the
195
+ * project's top), no deeper than its segment count when it has no `**`.
196
+ */
197
+ function reach(glob: string): readonly [prefix: string, maxDepth: number] {
198
+ const p = staticPrefix(glob)
199
+ return [p === '.' || p === '/' ? '' : p, glob.includes('**') ? Infinity : glob.split('/').length]
200
+ }
201
+
202
+ /** The directories a task's own outputs cover whole (`dist/**`, `dist`): none of its inputs lie there. */
203
+ function ownTrees(globs: readonly string[]): string[] {
204
+ return asTrees(globs).flatMap((g) => {
205
+ const p = staticPrefix(g)
206
+ return p !== '.' && p !== '/' && g === `${p}/**` ? [p] : []
207
+ })
208
+ }
209
+
147
210
  /** `p` with its longest existing prefix realpath'd: the cache dir may not exist yet. */
148
211
  function realThrough(p: string): string {
149
212
  try {
@@ -312,6 +375,11 @@ export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: s
312
375
  */
313
376
  const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
314
377
 
378
+ /** A project's own config file, by name: a run reads it only through the lock. */
379
+ export function isProjectConfigFile(filename: string): boolean {
380
+ return PROJECT_CONFIGS.has(path.basename(filename))
381
+ }
382
+
315
383
  export function shapesWatchedSet(filename: string): boolean {
316
384
  const base = path.basename(filename)
317
385
  return PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(filename.split(path.sep).join('/'))
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
+ import { ui } from '../util/index.js'
7
8
 
8
9
  /** Directories whose changes never trigger a re-run, wherever they appear. */
9
10
  export const IGNORED_SEGMENTS = ['node_modules', '.git']
@@ -103,7 +104,20 @@ export function pollWatcher(
103
104
  if (e.name === WATCH_PROBE) continue
104
105
  const childRel = rel === '' ? e.name : `${rel}/${e.name}`
105
106
  if (e.isDirectory()) {
106
- if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
107
+ if (recursive) {
108
+ if (!skipDir(childRel)) walk(path.join(abs, e.name), childRel)
109
+ } else {
110
+ // A non-recursive arm is there for a name coming or going (a
111
+ // project's parent hears the project made again), as the OS
112
+ // watcher reports it: by the directory's identity, not its
113
+ // times, which every write inside moves.
114
+ try {
115
+ const st = fs.statSync(path.join(abs, e.name))
116
+ current.set(childRel, `dir:${st.dev}:${st.ino}:${st.birthtimeMs}`)
117
+ } catch {
118
+ // raced with a delete; the next scan settles it
119
+ }
120
+ }
107
121
  continue
108
122
  }
109
123
  // A link is an input as its target string (watch-judge.ts), so that
@@ -310,7 +324,8 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
310
324
  if (rel === '' || (limit && !armed)) throw err
311
325
  if (limit && !warned) {
312
326
  warned = true
313
- process.stderr.write(
327
+ ui.say(
328
+ 'warn',
314
329
  `vx watch: ${abs}: the OS watch limit is reached (${code}); directories made from here on are not watched — raise it (Linux: sysctl fs.inotify.max_user_watches)\n`,
315
330
  )
316
331
  }
@@ -420,8 +435,7 @@ export class WatcherPool {
420
435
  private readonly skip: (dir: string, rel: string) => boolean,
421
436
  private readonly forcePoll = false,
422
437
  ) {
423
- if (forcePoll)
424
- process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (watch.poll)\n`)
438
+ if (forcePoll) ui.say('info', `vx watch: polling every ${POLL_INTERVAL_MS} ms (watch.poll)\n`)
425
439
  }
426
440
 
427
441
  private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
@@ -453,7 +467,8 @@ export class WatcherPool {
453
467
  const code = (err as NodeJS.ErrnoException).code
454
468
  if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
455
469
  watchers.push(this.poll(dir, recursive, onEvent))
456
- process.stderr.write(
470
+ ui.say(
471
+ 'warn',
457
472
  `vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
458
473
  )
459
474
  return handle
@@ -469,7 +484,8 @@ export class WatcherPool {
469
484
  // stream the OS refused, a filesystem that reports nothing. Swap in
470
485
  // the poller rather than run a loop that silently never fires.
471
486
  watchers[at] = this.poll(dir, recursive, onEvent)
472
- process.stderr.write(
487
+ ui.say(
488
+ 'warn',
473
489
  `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
474
490
  )
475
491
  }),