@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,25 +1,45 @@
1
- // `vx upgrade [TAG]` — self-update the compiled binary in place.
2
- // Asks the GitHub release API for the asset of this os/arch and the
3
- // SHA-256 digest it publishes, downloads the asset, verifies the digest,
4
- // writes next to the current executable, atomic rename over it. The
5
- // digest is what makes a cut or corrupted transfer (a proxy that drops a
6
- // connection, a disk that fills) a refusal instead of a binary that does
7
- // not start: the rename is the last step, and a mismatch replaces
8
- // nothing. It guards the TRANSFER only: the digest comes from the same
9
- // release API as the download URL, and GitHub recomputes it when an asset
10
- // is uploaded, so whoever can replace the asset replaces its digest too
11
- // (item 1096). Nothing here verifies who built the binary.
1
+ // `vx upgrade [TAG]` — bring this vx to a newer release, the way it was
2
+ // installed. A compiled binary installed by hand replaces itself: it reads
3
+ // its platform package (`@vzn/vx-<os>-<arch>`) from the npm registry,
4
+ // checks npm's registry signature over `name@version:integrity` against
5
+ // the key pinned below, checks the tarball's SHA-512 against that signed
6
+ // integrity, and only then extracts the binary, stages it beside the old
7
+ // one and renames it over. The signature is what proves the bytes are the
8
+ // ones npm published: the host that serves the tarball cannot mint it
9
+ // (pnpm's installer, pnpm/get, checks the same). A vx installed by a
10
+ // package manager (npm, pnpm, bun, yarn; global or a project's
11
+ // dependency) is upgraded by that package manager, never by a rename over
12
+ // a file it owns. vx never installs Node or a package manager.
12
13
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
13
14
  // deno upgrade — "update" is what package managers do to indexes.
14
15
 
15
- import { constants } from 'node:fs'
16
+ import { constants, existsSync, readFileSync } from 'node:fs'
17
+ import { createPublicKey, verify } from 'node:crypto'
16
18
  import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
17
19
  import path from 'node:path'
18
20
  import { flagHint, seeHelp } from './help.js'
19
21
  import { UserError } from '../util/index.js'
20
22
  import { VERSION } from '../version.js'
21
23
 
22
- const REPO = 'vznjs/vx'
24
+ const REGISTRY = 'https://registry.npmjs.org'
25
+ const PACKAGE = '@vzn/vx'
26
+
27
+ /**
28
+ * npm's registry signing key, from https://registry.npmjs.org/-/npm/v1/keys.
29
+ * Pinned, not fetched: a key read from the registry would prove nothing
30
+ * about the registry's own answers. When npm rotates it, a release of vx
31
+ * carries the new one, and an older vx refuses with that hint.
32
+ */
33
+ export const NPM_SIGNING_KEY = {
34
+ id: 'SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U',
35
+ spki: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==',
36
+ }
37
+
38
+ /** A registry key: its id as npm names it, and its SPKI DER, base64. */
39
+ export interface SigningKey {
40
+ id: string
41
+ spki: string
42
+ }
23
43
 
24
44
  /**
25
45
  * A Bun standalone-binary path lives under the bunfs virtual root.
@@ -45,14 +65,15 @@ export function isCompiledBinary(): boolean {
45
65
  }
46
66
 
47
67
  /**
68
+ * The package dir/**
48
69
  * The package directory that owns `execPath` when it sits under a
49
70
  * `node_modules` — an npm install's platform binary, which the launcher
50
71
  * runs as `…/node_modules/@vzn/vx-<os>-<arch>/vx`. That file is npm's:
51
72
  * a rename over it upgrades this command until the next `npm install`
52
73
  * (or a lockfile-pinned CI checkout) puts the version npm knows back,
53
74
  * and `npm ls` never agrees with `vx --version` in between. Such an
54
- * install updates through npm, and `vx upgrade` says so. Null for a
55
- * binary installed by hand from a release.
75
+ * install updates through its package manager (`managedInstall`). Null
76
+ * for a binary installed by hand from a release.
56
77
  */
57
78
  export function npmOwnedBinary(execPath: string): string | null {
58
79
  const parts = execPath.split(/[\\/]/)
@@ -63,76 +84,23 @@ export function npmOwnedBinary(execPath: string): string | null {
63
84
  return pkg.length > 0 ? pkg : 'node_modules'
64
85
  }
65
86
 
66
- function assetName(): string {
67
- const os = process.platform === 'darwin' ? 'darwin' : process.platform
87
+ function platformPackage(): string {
88
+ const os = process.platform
68
89
  const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null
69
90
  if ((os !== 'darwin' && os !== 'linux') || arch === null) {
70
91
  throw new UserError(`vx upgrade: unsupported platform ${process.platform}/${process.arch}`)
71
92
  }
72
- return `vx-${os}-${arch}`
73
- }
74
-
75
- /** What the release API says about the asset this platform installs. */
76
- export interface ReleaseAsset {
77
- /** The release's tag, as the API names it (`v0.4.0`). */
78
- tag: string
79
- url: string
80
- /** Lower-case hex SHA-256 of the asset's bytes. */
81
- sha256: string
93
+ return `${PACKAGE}-${os}-${arch}`
82
94
  }
83
95
 
84
- /**
85
- * Pick this platform's asset out of a release document and read the
86
- * digest the API publishes for it (`sha256:<hex>` on every asset a
87
- * release built through `release.yml` carries). A release without the
88
- * asset, or an asset without a digest, is refused here: an upgrade that
89
- * cannot be verified is not attempted.
90
- */
91
- export function releaseAsset(release: unknown, name: string): ReleaseAsset {
92
- const tag =
93
- typeof release === 'object' &&
94
- release !== null &&
95
- typeof (release as { tag_name?: unknown }).tag_name === 'string'
96
- ? (release as { tag_name: string }).tag_name
97
- : '(unknown)'
98
- const assets =
99
- typeof release === 'object' &&
100
- release !== null &&
101
- Array.isArray((release as { assets?: unknown }).assets)
102
- ? (release as { assets: unknown[] }).assets
103
- : []
104
- const asset = assets.find(
105
- (a): a is { name: string; browser_download_url?: unknown; digest?: unknown } =>
106
- typeof a === 'object' && a !== null && (a as { name?: unknown }).name === name,
107
- )
108
- if (asset === undefined) {
109
- throw new UserError(
110
- `vx upgrade: release ${tag} has no asset ${name} for this platform — nothing replaced; npm install -g @vzn/vx@latest installs it instead`,
111
- )
112
- }
113
- const url = asset.browser_download_url
114
- if (typeof url !== 'string' || url.length === 0) {
115
- throw new UserError(
116
- `vx upgrade: release ${tag} names no download for ${name} — nothing replaced; npm install -g @vzn/vx@latest installs it instead`,
117
- )
118
- }
119
- const digest = asset.digest
120
- const m = typeof digest === 'string' ? /^sha256:([0-9a-f]{64})$/i.exec(digest) : null
121
- if (m === null) {
122
- throw new UserError(
123
- `vx upgrade: release ${tag} publishes no SHA-256 digest for ${name} — nothing to verify the download against, so it is not attempted`,
124
- )
96
+ /** The registry spec for `TAG`: `v0.4.0` and `0.4.0` name a version; none is `latest`. */
97
+ export function versionSpec(tag: string | undefined): string {
98
+ if (tag === undefined) return 'latest'
99
+ const v = tag.startsWith('v') ? tag.slice(1) : tag
100
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
101
+ throw new UserError(`vx upgrade: ${tag} is not a version (expected v<major>.<minor>.<patch>)`)
125
102
  }
126
- return { tag, url, sha256: m[1]!.toLowerCase() }
127
- }
128
-
129
- /**
130
- * Whether a release tag names the version this binary is: the upgrade
131
- * re-downloaded and replaced a binary with itself, printing `X → latest`
132
- * (item 1098). Tags are `v<version>`; a bare version is accepted too.
133
- */
134
- export function isThisVersion(tag: string, version: string): boolean {
135
- return tag === `v${version}` || tag === version
103
+ return v
136
104
  }
137
105
 
138
106
  /**
@@ -171,31 +139,17 @@ async function readOrRefuse<T>(read: () => Promise<T>, url: string, what: string
171
139
  }
172
140
  }
173
141
 
174
- /** The release document for `latest` or a tag, from the GitHub API. */
175
- export async function fetchRelease(tag: string | undefined): Promise<unknown> {
176
- const url =
177
- tag === undefined
178
- ? `https://api.github.com/repos/${REPO}/releases/latest`
179
- : `https://api.github.com/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`
142
+ /** The registry's document for `pkg@spec` (`latest` or a version). */
143
+ export async function fetchManifest(pkg: string, spec: string): Promise<unknown> {
144
+ const url = `${REGISTRY}/${pkg}/${spec}`
180
145
  const res = await fetchOrRefuse(
181
146
  url,
182
- {
183
- redirect: 'follow',
184
- headers: { Accept: 'application/vnd.github+json', 'User-Agent': `vx/${VERSION}` },
185
- },
147
+ { redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
186
148
  'read the release',
187
149
  )
188
150
  if (!res.ok) {
189
- // GitHub answers an unauthenticated address past its hourly budget with
190
- // 403 (or 429) and says so in the headers; "could not read the release
191
- // (403)" read as a permissions fault (item 1098).
192
- const reset = Number(res.headers.get('x-ratelimit-reset'))
193
- const limited =
194
- (res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0'
195
151
  throw new UserError(
196
- limited
197
- ? `vx upgrade: GitHub's API rate limit for this address is spent${Number.isFinite(reset) && reset > 0 ? ` until ${new Date(reset * 1000).toISOString()}` : ''} — re-run after that`
198
- : `vx upgrade: could not read the release (${res.status}) — ${url}${res.status === 404 && tag !== undefined ? ` (is ${tag} a release tag?)` : ''}`,
152
+ `vx upgrade: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
199
153
  )
200
154
  }
201
155
  // A body that is not JSON (a captive portal's page, a proxy's error
@@ -203,49 +157,184 @@ export async function fetchRelease(tag: string | undefined): Promise<unknown> {
203
157
  return await readOrRefuse(() => res.json(), url, 'read the release')
204
158
  }
205
159
 
160
+ /** What a verified registry document names: the version, its tarball and the signed integrity. */
161
+ export interface SignedRelease {
162
+ version: string
163
+ tarball: string
164
+ integrity: string
165
+ }
166
+
206
167
  /**
207
- * Download `url`, verify its SHA-256 against `sha256`, and atomically
208
- * replace `dest` with it, keeping `dest`'s mode (and, as root, its owner):
209
- * a 0750 install came back 0755 and owned by whoever upgraded (item 1097).
210
- * `starts`, when given, is asked of the replaced binary; false puts the old
211
- * one back, since the digest proves the bytes, not that this machine can
212
- * run them (a CPU below the build's target, a `noexec` mount), and the
213
- * upgrade reported success over a vx that no longer started. Exported for
214
- * tests (which stub `fetch` and point `dest` at a tmp file); the CLI wires
215
- * it to the release asset and process.execPath.
168
+ * The tarball and integrity of `pkg` in `manifest`, once npm's registry
169
+ * signature over `pkg@version:integrity` verifies against `key`. Every
170
+ * refusal comes before a byte of the tarball is fetched. The message is
171
+ * built from the package vx asked for, so a signature npm made for
172
+ * another package does not verify here.
216
173
  */
217
- export async function replaceBinary(
218
- dest: string,
219
- url: string,
220
- sha256: string,
221
- starts?: (dest: string) => boolean,
222
- ): Promise<void> {
223
- // The swap writes beside the binary: a directory this user cannot write
224
- // (a root-owned /usr/local/bin) failed at the rename, the whole release
225
- // downloaded for nothing, and the hint named npm, which this binary is not.
226
- const dir = path.dirname(dest)
227
- await access(dir, constants.W_OK).catch((err: NodeJS.ErrnoException) => {
174
+ export function verifiedRelease(
175
+ manifest: unknown,
176
+ pkg: string,
177
+ key: SigningKey = NPM_SIGNING_KEY,
178
+ ): SignedRelease {
179
+ const m = (typeof manifest === 'object' && manifest !== null ? manifest : {}) as {
180
+ version?: unknown
181
+ dist?: { tarball?: unknown; integrity?: unknown; signatures?: unknown }
182
+ }
183
+ const version = typeof m.version === 'string' ? m.version : ''
184
+ const tarball = m.dist?.tarball
185
+ const integrity = m.dist?.integrity
186
+ if (version === '' || typeof tarball !== 'string' || !tarball.startsWith('https://')) {
228
187
  throw new UserError(
229
- `vx upgrade: cannot write to ${dir} (${err.code ?? err.message}), where this vx lives — nothing downloaded; re-run as a user who can (sudo vx upgrade), or install vx somewhere you can write`,
188
+ `vx upgrade: the npm registry published no tarball for ${pkg}${version === '' ? '' : `@${version}`} — nothing replaced`,
230
189
  )
190
+ }
191
+ if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(integrity)) {
192
+ throw new UserError(
193
+ `vx upgrade: the npm registry published no SHA-512 integrity for ${pkg}@${version} — nothing to verify the download against, so it is not attempted`,
194
+ )
195
+ }
196
+ const signatures = Array.isArray(m.dist?.signatures)
197
+ ? (m.dist.signatures as unknown[]).filter(
198
+ (s): s is { keyid: string; sig: string } =>
199
+ typeof s === 'object' &&
200
+ s !== null &&
201
+ typeof (s as { keyid?: unknown }).keyid === 'string' &&
202
+ typeof (s as { sig?: unknown }).sig === 'string',
203
+ )
204
+ : []
205
+ if (signatures.length === 0) {
206
+ throw new UserError(
207
+ `vx upgrade: ${pkg}@${version} carries no npm registry signature, so it cannot be verified — nothing replaced`,
208
+ )
209
+ }
210
+ const ours = signatures.filter((s) => s.keyid === key.id)
211
+ if (ours.length === 0) {
212
+ throw new UserError(
213
+ `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`,
214
+ )
215
+ }
216
+ const publicKey = createPublicKey({
217
+ key: Buffer.from(key.spki, 'base64'),
218
+ format: 'der',
219
+ type: 'spki',
220
+ })
221
+ const message = Buffer.from(`${pkg}@${version}:${integrity}`)
222
+ const valid = ours.some((s) => {
223
+ try {
224
+ return verify('sha256', message, publicKey, Buffer.from(s.sig, 'base64'))
225
+ } catch {
226
+ return false
227
+ }
231
228
  })
232
- const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release asset')
229
+ if (!valid) {
230
+ throw new UserError(
231
+ `vx upgrade: the npm registry signature for ${pkg}@${version} is not valid — refusing to install; nothing replaced`,
232
+ )
233
+ }
234
+ return { version, tarball, integrity }
235
+ }
236
+
237
+ /** Download `release.tarball` and check its bytes against the signed SHA-512. */
238
+ async function downloadVerified(release: SignedRelease): Promise<Uint8Array> {
239
+ const url = release.tarball
240
+ const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release')
233
241
  if (!res.ok) {
234
242
  throw new UserError(
235
243
  `vx upgrade: download failed (${res.status}) — ${url} — nothing replaced; re-run`,
236
244
  )
237
245
  }
238
246
  const bytes = new Uint8Array(
239
- await readOrRefuse(() => res.arrayBuffer(), url, 'download the release asset'),
247
+ await readOrRefuse(() => res.arrayBuffer(), url, 'download the release'),
240
248
  )
241
- if (bytes.byteLength === 0) {
242
- throw new UserError(`vx upgrade: empty download — ${url} — nothing replaced; re-run`)
249
+ const got = `sha512-${new Bun.CryptoHasher('sha512').update(bytes).digest('base64')}`
250
+ if (got !== release.integrity) {
251
+ throw new UserError(
252
+ `vx upgrade: the download did not match the integrity npm signed (expected ${release.integrity}, got ${got}) — nothing replaced; try again, and if it repeats the tarball is not the one npm published`,
253
+ )
243
254
  }
244
- const got = new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
245
- if (got !== sha256.toLowerCase()) {
255
+ return bytes
256
+ }
257
+
258
+ /**
259
+ * The `package/vx` file of a platform package's tarball (gzip'd ustar, as
260
+ * npm packs it). Only a regular file by that name is taken: the tarball is
261
+ * already verified, so this reads, it does not defend.
262
+ */
263
+ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
264
+ let tar: Uint8Array
265
+ try {
266
+ tar = Bun.gunzipSync(tgz as Uint8Array<ArrayBuffer>)
267
+ } catch {
268
+ throw new UserError('vx upgrade: the release tarball is not gzip — nothing replaced')
269
+ }
270
+ const text = (b: Uint8Array): string => new TextDecoder().decode(b).replace(/\0[\s\S]*$/, '')
271
+ for (let at = 0; at + 512 <= tar.length;) {
272
+ const head = tar.subarray(at, at + 512)
273
+ const name = text(head.subarray(0, 100))
274
+ if (name === '') break
275
+ const prefix = text(head.subarray(257, 263)) === 'ustar' ? text(head.subarray(345, 500)) : ''
276
+ const size = Number.parseInt(text(head.subarray(124, 136)).trim() || '0', 8)
277
+ const type = head[156]
278
+ const body = at + 512
279
+ if (
280
+ (prefix === '' ? name : `${prefix}/${name}`) === 'package/vx' &&
281
+ (type === 0x30 || type === 0)
282
+ ) {
283
+ return tar.slice(body, body + size)
284
+ }
285
+ at = body + Math.ceil(size / 512) * 512
286
+ }
287
+ throw new UserError('vx upgrade: the release tarball holds no package/vx — nothing replaced')
288
+ }
289
+
290
+ /**
291
+ * The binary of `pkg@spec`, verified: the registry document's signature,
292
+ * then the tarball's integrity, then the extraction, each refusing before
293
+ * the next step runs. Null when the release is this vx's own version;
294
+ * `announce` hears the version before the download starts.
295
+ */
296
+ export async function releaseBinary(
297
+ pkg: string,
298
+ spec: string,
299
+ announce: (version: string) => void,
300
+ key: SigningKey = NPM_SIGNING_KEY,
301
+ ): Promise<Uint8Array | null> {
302
+ const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
303
+ if (release.version === VERSION) return null
304
+ announce(release.version)
305
+ return binaryFromTarball(await downloadVerified(release))
306
+ }
307
+
308
+ /**
309
+ * Replace `dest` with the bytes `fetchBinary` returns (null: nothing to
310
+ * do, and false is returned), keeping `dest`'s
311
+ * mode (and, as root, its owner): a 0750 install came back 0755 and owned
312
+ * by whoever upgraded (item 1097). Whether this user can write beside
313
+ * `dest` is checked before `fetchBinary` runs, so a refusal downloads
314
+ * nothing. `starts`, when given, is asked of the replaced binary; false
315
+ * puts the old one back, since a verified download proves the bytes, not
316
+ * that this machine can run them (a CPU below the build's target, a
317
+ * `noexec` mount), and the upgrade reported success over a vx that no
318
+ * longer started.
319
+ */
320
+ export async function replaceBinary(
321
+ dest: string,
322
+ fetchBinary: () => Promise<Uint8Array | null>,
323
+ starts?: (dest: string) => boolean,
324
+ ): Promise<boolean> {
325
+ // The swap writes beside the binary: a directory this user cannot write
326
+ // (a root-owned /usr/local/bin) failed at the rename, the whole release
327
+ // downloaded for nothing, and the hint named npm, which this binary is not.
328
+ const dir = path.dirname(dest)
329
+ await access(dir, constants.W_OK).catch((err: NodeJS.ErrnoException) => {
246
330
  throw new UserError(
247
- `vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
331
+ `vx upgrade: cannot write to ${dir} (${err.code ?? err.message}), where this vx lives — nothing downloaded; re-run as a user who can (sudo vx upgrade), or install vx somewhere you can write`,
248
332
  )
333
+ })
334
+ const bytes = await fetchBinary()
335
+ if (bytes === null) return false
336
+ if (bytes.byteLength === 0) {
337
+ throw new UserError(`vx upgrade: the release's binary is empty — nothing replaced`)
249
338
  }
250
339
  // A fresh directory beside the binary, not a name built from the pid:
251
340
  // anyone who can write the install directory could plant a link at a
@@ -285,7 +374,7 @@ export async function replaceBinary(
285
374
  }
286
375
  if (starts === undefined || starts(dest)) {
287
376
  await rm(stage, { recursive: true, force: true })
288
- return
377
+ return true
289
378
  }
290
379
  if (kept) await rename(old, dest)
291
380
  await rm(stage, { recursive: true, force: true })
@@ -294,6 +383,171 @@ export async function replaceBinary(
294
383
  )
295
384
  }
296
385
 
386
+ /** How a package manager installed this vx: who owns it, and where to run the update. */
387
+ export interface ManagedInstall {
388
+ manager: 'npm' | 'pnpm' | 'bun' | 'yarn'
389
+ /** `global`: the manager's own global tree; `project`: a dependency of `cwd`'s package.json. */
390
+ scope: 'global' | 'project'
391
+ cwd: string
392
+ /** The project field that lists `@vzn/vx`. */
393
+ field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
394
+ }
395
+
396
+ const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
397
+
398
+ function readJson(file: string): Record<string, unknown> | null {
399
+ try {
400
+ const v: unknown = JSON.parse(readFileSync(file, 'utf8'))
401
+ return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : null
402
+ } catch {
403
+ return null
404
+ }
405
+ }
406
+
407
+ /** The field of `dir`'s package.json that lists `@vzn/vx`, if one does. */
408
+ function listingField(dir: string): ManagedInstall['field'] | undefined {
409
+ const pkg = readJson(path.join(dir, 'package.json'))
410
+ return FIELDS.find((f) => {
411
+ const deps = pkg?.[f]
412
+ return typeof deps === 'object' && deps !== null && PACKAGE in deps
413
+ })
414
+ }
415
+
416
+ /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
417
+ function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
418
+ for (let d = dir; ; d = path.dirname(d)) {
419
+ if (existsSync(path.join(d, 'pnpm-lock.yaml'))) return 'pnpm'
420
+ if (existsSync(path.join(d, 'bun.lock')) || existsSync(path.join(d, 'bun.lockb'))) return 'bun'
421
+ if (existsSync(path.join(d, 'yarn.lock'))) return 'yarn'
422
+ if (
423
+ existsSync(path.join(d, 'package-lock.json')) ||
424
+ existsSync(path.join(d, 'npm-shrinkwrap.json'))
425
+ )
426
+ return 'npm'
427
+ const declared = readJson(path.join(d, 'package.json'))?.['packageManager']
428
+ if (typeof declared === 'string') {
429
+ const name = declared.split('@')[0]
430
+ if (name === 'pnpm' || name === 'bun' || name === 'yarn' || name === 'npm') return name
431
+ }
432
+ if (path.dirname(d) === d) break
433
+ }
434
+ return execPath.includes(`${path.sep}node_modules${path.sep}.pnpm${path.sep}`) ? 'pnpm' : 'npm'
435
+ }
436
+
437
+ /**
438
+ * How a package manager installed the binary at `execPath`, or null when
439
+ * none did (a release binary installed by hand). The launcher runs the
440
+ * platform package's binary, `…/node_modules/@vzn/vx-<os>-<arch>/vx`, so
441
+ * the path names the tree: a manager's global directory, or a project
442
+ * whose package.json lists `@vzn/vx` (the tree's root, else the nearest
443
+ * one above `cwd`, for a workspace whose dependencies are hoisted). A
444
+ * package-manager cache (npx, bunx) is refused: nothing there to upgrade.
445
+ */
446
+ export function managedInstall(execPath: string, cwd: string): ManagedInstall | null {
447
+ const owner = npmOwnedBinary(execPath)
448
+ if (owner === null) return null
449
+ const p = execPath.split(path.sep).join('/')
450
+ const root = p.slice(0, `${p}/`.indexOf('/node_modules/'))
451
+ if (p.includes('/_npx/') || /\/bunx-[^/]*\//.test(p)) {
452
+ throw new UserError(
453
+ `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`,
454
+ )
455
+ }
456
+ if (p.includes('/.bun/install/global/')) return { manager: 'bun', scope: 'global', cwd: root }
457
+ if (/\/pnpm\/global\/|\/pnpm-global\//.test(p))
458
+ return { manager: 'pnpm', scope: 'global', cwd: root }
459
+ if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
460
+ const field = listingField(root)
461
+ if (field !== undefined) {
462
+ return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field }
463
+ }
464
+ for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
465
+ const f = listingField(d)
466
+ if (f !== undefined)
467
+ return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f }
468
+ if (path.dirname(d) === d) break
469
+ }
470
+ // npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
471
+ if (path.basename(root) === 'lib' && !existsSync(path.join(root, 'package.json'))) {
472
+ return { manager: 'npm', scope: 'global', cwd: root }
473
+ }
474
+ throw new UserError(
475
+ `vx upgrade: this vx was installed by a package manager (${execPath} belongs to ${owner}), but no package.json from ${cwd} up lists ${PACKAGE} — run the upgrade with the manager that installed it, e.g. npm install -g ${PACKAGE}@latest`,
476
+ )
477
+ }
478
+
479
+ /** The argv that brings `install` to `version`. */
480
+ export function upgradeArgv(install: ManagedInstall, version: string): string[] {
481
+ const spec = `${PACKAGE}@${version}`
482
+ const { manager } = install
483
+ if (install.scope === 'global') {
484
+ if (manager === 'yarn') return ['yarn', 'global', 'add', spec]
485
+ return manager === 'npm' ? ['npm', 'install', '-g', spec] : [manager, 'add', '-g', spec]
486
+ }
487
+ const field = install.field ?? 'devDependencies'
488
+ const flag = {
489
+ npm: {
490
+ devDependencies: '--save-dev',
491
+ dependencies: '--save',
492
+ optionalDependencies: '--save-optional',
493
+ },
494
+ pnpm: {
495
+ devDependencies: '--save-dev',
496
+ dependencies: '--save-prod',
497
+ optionalDependencies: '--save-optional',
498
+ },
499
+ bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
500
+ yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
501
+ }[manager][field]
502
+ const argv = manager === 'npm' ? ['npm', 'install', spec] : [manager, 'add', spec]
503
+ if (flag !== '') argv.push(flag)
504
+ // pnpm refuses an `add` at a workspace's root without saying it means the root.
505
+ if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
506
+ argv.push('--workspace-root')
507
+ return argv
508
+ }
509
+
510
+ /** The version `tag` names, or the registry's latest, read for the "already at" check. */
511
+ async function targetVersion(tag: string | undefined): Promise<string> {
512
+ const spec = versionSpec(tag)
513
+ if (spec !== 'latest') return spec
514
+ const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)?.version
515
+ if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
516
+ throw new UserError(`vx upgrade: the npm registry named no latest version of ${PACKAGE}`)
517
+ }
518
+ return v
519
+ }
520
+
521
+ /** Run the package manager that owns this vx; vx never installs one. */
522
+ async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
523
+ const version = await targetVersion(tag)
524
+ if (version === VERSION) {
525
+ process.stdout.write(`vx upgrade: already at ${VERSION} — nothing to do\n`)
526
+ return 0
527
+ }
528
+ const argv = upgradeArgv(install, version)
529
+ const bin = Bun.which(argv[0]!)
530
+ if (bin === null) {
531
+ throw new UserError(
532
+ `vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
533
+ )
534
+ }
535
+ process.stdout.write(
536
+ `vx upgrade: ${VERSION} → ${version}: running ${argv.join(' ')} (in ${install.cwd})\n`,
537
+ )
538
+ const proc = Bun.spawnSync({
539
+ cmd: [bin, ...argv.slice(1)],
540
+ cwd: install.cwd,
541
+ stdio: ['inherit', 'inherit', 'inherit'],
542
+ })
543
+ if (proc.exitCode !== 0) {
544
+ throw new UserError(
545
+ `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
546
+ )
547
+ }
548
+ return 0
549
+ }
550
+
297
551
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
298
552
  const unknown = args.find((a) => a.startsWith('-'))
299
553
  if (unknown !== undefined) {
@@ -316,26 +570,29 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
316
570
  )
317
571
  }
318
572
  const dest = process.execPath
319
- const owner = npmOwnedBinary(dest)
320
- if (owner !== null) {
321
- throw new UserError(
322
- `vx upgrade: this vx was installed by npm (${dest} belongs to ${owner}) — npm owns that file, and the next npm install would put the version it knows back. Update with: npm install -g @vzn/vx@latest`,
323
- )
324
- }
325
- const asset = releaseAsset(await fetchRelease(tag), assetName())
326
- if (isThisVersion(asset.tag, VERSION)) {
327
- process.stdout.write(`vx upgrade: already at ${VERSION} (${asset.tag}) — nothing to do\n`)
328
- return 0
329
- }
330
- process.stdout.write(`vx upgrade: ${VERSION} → ${tag ?? 'latest'} (${dest})\n`)
331
- // The replaced binary's own version: the new build speaks for itself
332
- // rather than this process guessing, and one that cannot is rolled back.
573
+ const install = managedInstall(dest, process.cwd())
574
+ if (install !== null) return await upgradeManaged(install, tag)
575
+ const pkg = platformPackage()
576
+ const spec = versionSpec(tag)
333
577
  let installed = ''
334
- await replaceBinary(dest, asset.url, asset.sha256, (bin) => {
335
- installed = startedVersion(bin) ?? ''
336
- return installed !== ''
337
- })
338
- process.stdout.write(`vx upgrade: installed ${installed}\n`)
578
+ const replaced = await replaceBinary(
579
+ dest,
580
+ () =>
581
+ releaseBinary(pkg, spec, (version) =>
582
+ process.stdout.write(`vx upgrade: ${VERSION} → ${version} (${dest})\n`),
583
+ ),
584
+ // The replaced binary's own version: the new build speaks for itself
585
+ // rather than this process guessing, and one that cannot is rolled back.
586
+ (bin) => {
587
+ installed = startedVersion(bin) ?? ''
588
+ return installed !== ''
589
+ },
590
+ )
591
+ process.stdout.write(
592
+ replaced
593
+ ? `vx upgrade: installed ${installed}\n`
594
+ : `vx upgrade: already at ${VERSION} — nothing to do\n`,
595
+ )
339
596
  return 0
340
597
  }
341
598