@vzn/vx 0.0.636 → 0.0.638
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
// `vx upgrade [TAG]` —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
import { UserError } from '../util/index.js'
|
|
21
|
+
import { UserError, sayNotice, ui } from '../util/index.js'
|
|
20
22
|
import { VERSION } from '../version.js'
|
|
21
23
|
|
|
22
|
-
const
|
|
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
|
|
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
|
|
67
|
-
const os = 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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
175
|
-
export async function
|
|
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
|
-
|
|
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
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
247
|
+
await readOrRefuse(() => res.arrayBuffer(), url, 'download the release'),
|
|
240
248
|
)
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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:
|
|
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,11 +383,180 @@ 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
|
+
ui.print('out', ui.status('info', 'vx upgrade', `already at ${VERSION}`))
|
|
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
|
+
ui.print(
|
|
536
|
+
'out',
|
|
537
|
+
ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
|
|
538
|
+
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
539
|
+
)
|
|
540
|
+
const proc = Bun.spawnSync({
|
|
541
|
+
cmd: [bin, ...argv.slice(1)],
|
|
542
|
+
cwd: install.cwd,
|
|
543
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
544
|
+
})
|
|
545
|
+
if (proc.exitCode !== 0) {
|
|
546
|
+
throw new UserError(
|
|
547
|
+
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
return 0
|
|
551
|
+
}
|
|
552
|
+
|
|
297
553
|
export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
298
554
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
299
555
|
if (unknown !== undefined) {
|
|
300
|
-
|
|
301
|
-
|
|
556
|
+
sayNotice(
|
|
557
|
+
'error',
|
|
558
|
+
'vx upgrade',
|
|
559
|
+
`unknown flag: ${unknown}${flagHint('upgrade', unknown)}${seeHelp('upgrade')}`,
|
|
302
560
|
)
|
|
303
561
|
return 1
|
|
304
562
|
}
|
|
@@ -306,7 +564,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
306
564
|
// the first one went in (`vx upgrade v0.0.22 v0.0.23`).
|
|
307
565
|
const [tag, extra] = args
|
|
308
566
|
if (extra !== undefined) {
|
|
309
|
-
|
|
567
|
+
sayNotice('error', 'vx upgrade', `unexpected argument: ${extra}${seeHelp('upgrade')}`)
|
|
310
568
|
return 1
|
|
311
569
|
}
|
|
312
570
|
if (!isCompiledBinary()) {
|
|
@@ -316,26 +574,34 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
316
574
|
)
|
|
317
575
|
}
|
|
318
576
|
const dest = process.execPath
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
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.
|
|
577
|
+
const install = managedInstall(dest, process.cwd())
|
|
578
|
+
if (install !== null) return await upgradeManaged(install, tag)
|
|
579
|
+
const pkg = platformPackage()
|
|
580
|
+
const spec = versionSpec(tag)
|
|
333
581
|
let installed = ''
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
582
|
+
const replaced = await replaceBinary(
|
|
583
|
+
dest,
|
|
584
|
+
() =>
|
|
585
|
+
releaseBinary(pkg, spec, (version) =>
|
|
586
|
+
ui.print(
|
|
587
|
+
'out',
|
|
588
|
+
ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
|
|
589
|
+
ui.text(ui.dim(dest)),
|
|
590
|
+
),
|
|
591
|
+
),
|
|
592
|
+
// The replaced binary's own version: the new build speaks for itself
|
|
593
|
+
// rather than this process guessing, and one that cannot is rolled back.
|
|
594
|
+
(bin) => {
|
|
595
|
+
installed = startedVersion(bin) ?? ''
|
|
596
|
+
return installed !== ''
|
|
597
|
+
},
|
|
598
|
+
)
|
|
599
|
+
ui.print(
|
|
600
|
+
'out',
|
|
601
|
+
replaced
|
|
602
|
+
? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
|
|
603
|
+
: ui.status('info', 'vx upgrade', `already at ${VERSION}`),
|
|
604
|
+
)
|
|
339
605
|
return 0
|
|
340
606
|
}
|
|
341
607
|
|