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