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