@vzn/vx 0.0.638 → 0.0.640
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 +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/cli/upgrade.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// package manager (npm, pnpm, bun, yarn; global or a project's
|
|
11
11
|
// dependency) is upgraded by that package manager, never by a rename over
|
|
12
12
|
// a file it owns. vx never installs Node or a package manager.
|
|
13
|
+
// Handed a call by a global vx, the project's vx moves that global too,
|
|
14
|
+
// to the same version (`upgradeGlobal`).
|
|
13
15
|
// Named `upgrade` (not `update`) per CLI convention: bun upgrade,
|
|
14
16
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
15
17
|
|
|
@@ -110,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
|
|
|
110
112
|
* as an internal error with a stack (a network namespace without a
|
|
111
113
|
* route, 2026-09-16). One line, the host and the reason.
|
|
112
114
|
*/
|
|
113
|
-
async function fetchOrRefuse(
|
|
115
|
+
async function fetchOrRefuse(
|
|
116
|
+
url: string,
|
|
117
|
+
init: RequestInit,
|
|
118
|
+
what: string,
|
|
119
|
+
verb = 'vx upgrade',
|
|
120
|
+
): Promise<Response> {
|
|
114
121
|
try {
|
|
115
122
|
return await fetch(url, init)
|
|
116
123
|
} catch (err) {
|
|
117
124
|
const message = err instanceof Error ? err.message : String(err)
|
|
118
125
|
throw new UserError(
|
|
119
|
-
|
|
126
|
+
`${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
|
|
120
127
|
)
|
|
121
128
|
}
|
|
122
129
|
}
|
|
@@ -128,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
|
|
|
128
135
|
* connection was closed unexpectedly"), which `fetchOrRefuse` never sees
|
|
129
136
|
* and the CLI printed as an internal error with a stack.
|
|
130
137
|
*/
|
|
131
|
-
async function readOrRefuse<T>(
|
|
138
|
+
async function readOrRefuse<T>(
|
|
139
|
+
read: () => Promise<T>,
|
|
140
|
+
url: string,
|
|
141
|
+
what: string,
|
|
142
|
+
verb = 'vx upgrade',
|
|
143
|
+
): Promise<T> {
|
|
132
144
|
try {
|
|
133
145
|
return await read()
|
|
134
146
|
} catch (err) {
|
|
135
147
|
const message = err instanceof Error ? err.message : String(err)
|
|
136
148
|
throw new UserError(
|
|
137
|
-
|
|
149
|
+
`${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
|
|
138
150
|
)
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
/** The registry's document for `pkg@spec` (`latest` or a version). */
|
|
143
|
-
export async function fetchManifest(
|
|
155
|
+
export async function fetchManifest(
|
|
156
|
+
pkg: string,
|
|
157
|
+
spec: string,
|
|
158
|
+
verb = 'vx upgrade',
|
|
159
|
+
): Promise<unknown> {
|
|
144
160
|
const url = `${REGISTRY}/${pkg}/${spec}`
|
|
145
161
|
const res = await fetchOrRefuse(
|
|
146
162
|
url,
|
|
147
163
|
{ redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
|
|
148
164
|
'read the release',
|
|
165
|
+
verb,
|
|
149
166
|
)
|
|
150
167
|
if (!res.ok) {
|
|
151
168
|
throw new UserError(
|
|
152
|
-
|
|
169
|
+
`${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
|
|
153
170
|
)
|
|
154
171
|
}
|
|
155
172
|
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
156
173
|
// page served with a 200) is the same refusal as a cut one.
|
|
157
|
-
return await readOrRefuse(() => res.json(), url, 'read the release')
|
|
174
|
+
return await readOrRefuse(() => res.json(), url, 'read the release', verb)
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
/** What a verified registry document names: the version, its tarball and the signed integrity. */
|
|
@@ -290,17 +307,19 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
|
|
|
290
307
|
/**
|
|
291
308
|
* The binary of `pkg@spec`, verified: the registry document's signature,
|
|
292
309
|
* then the tarball's integrity, then the extraction, each refusing before
|
|
293
|
-
* the next step runs. Null when the release is
|
|
294
|
-
* `announce` hears the version before the download
|
|
310
|
+
* the next step runs. Null when the release is `current`, the version of
|
|
311
|
+
* the vx being replaced; `announce` hears the version before the download
|
|
312
|
+
* starts.
|
|
295
313
|
*/
|
|
296
314
|
export async function releaseBinary(
|
|
297
315
|
pkg: string,
|
|
298
316
|
spec: string,
|
|
299
317
|
announce: (version: string) => void,
|
|
300
318
|
key: SigningKey = NPM_SIGNING_KEY,
|
|
319
|
+
current = VERSION,
|
|
301
320
|
): Promise<Uint8Array | null> {
|
|
302
321
|
const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
|
|
303
|
-
if (release.version ===
|
|
322
|
+
if (release.version === current) return null
|
|
304
323
|
announce(release.version)
|
|
305
324
|
return binaryFromTarball(await downloadVerified(release))
|
|
306
325
|
}
|
|
@@ -391,6 +410,8 @@ export interface ManagedInstall {
|
|
|
391
410
|
cwd: string
|
|
392
411
|
/** The project field that lists `@vzn/vx`. */
|
|
393
412
|
field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
|
|
413
|
+
/** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
|
|
414
|
+
plugins?: string[]
|
|
394
415
|
}
|
|
395
416
|
|
|
396
417
|
const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
|
|
@@ -413,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
|
|
|
413
434
|
})
|
|
414
435
|
}
|
|
415
436
|
|
|
437
|
+
/** A release's platform binary, which the launcher pulls in itself: no plugin. */
|
|
438
|
+
const PLATFORM = /^@vzn\/vx-(?:darwin|linux)-(?:x64|arm64)$/
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The `@vzn/vx-*` plugins `dir`'s package.json lists from the registry
|
|
442
|
+
* (a `workspace:` or `file:` link is the repo's own). Every @vzn package
|
|
443
|
+
* releases together, so they move with core in its one install (M,
|
|
444
|
+
* 2026-10-16); one in another field would move fields under core's flag,
|
|
445
|
+
* so it is refused.
|
|
446
|
+
*/
|
|
447
|
+
function pluginsBeside(dir: string, field: NonNullable<ManagedInstall['field']>): string[] {
|
|
448
|
+
const pkg = readJson(path.join(dir, 'package.json'))
|
|
449
|
+
const plugins: string[] = []
|
|
450
|
+
for (const f of FIELDS) {
|
|
451
|
+
const deps = pkg?.[f]
|
|
452
|
+
if (typeof deps !== 'object' || deps === null) continue
|
|
453
|
+
for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
|
|
454
|
+
if (!name.startsWith(`${PACKAGE}-`) || PLATFORM.test(name)) continue
|
|
455
|
+
if (typeof spec !== 'string' || /^(?:workspace|file|link|portal):/.test(spec)) continue
|
|
456
|
+
if (f !== field) {
|
|
457
|
+
throw new UserError(
|
|
458
|
+
`vx upgrade: ${name} is in ${f} and ${PACKAGE} in ${field}; list them in one field so one install moves them together`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
plugins.push(name)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return plugins.sort()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The version of `pkg` installed where `dir` resolves it, if any. */
|
|
468
|
+
function installedVersion(dir: string, pkg: string): string | undefined {
|
|
469
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
470
|
+
const v = readJson(path.join(d, 'node_modules', pkg, 'package.json'))?.['version']
|
|
471
|
+
if (typeof v === 'string') return v
|
|
472
|
+
if (path.dirname(d) === d) return undefined
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
416
476
|
/** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
|
|
417
477
|
function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
|
|
418
478
|
for (let d = dir; ; d = path.dirname(d)) {
|
|
@@ -459,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
|
|
|
459
519
|
if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
|
|
460
520
|
const field = listingField(root)
|
|
461
521
|
if (field !== undefined) {
|
|
462
|
-
|
|
522
|
+
const plugins = pluginsBeside(root, field)
|
|
523
|
+
return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
|
|
463
524
|
}
|
|
464
525
|
for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
|
|
465
526
|
const f = listingField(d)
|
|
466
|
-
if (f !== undefined)
|
|
467
|
-
|
|
527
|
+
if (f !== undefined) {
|
|
528
|
+
const plugins = pluginsBeside(d, f)
|
|
529
|
+
return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f, plugins }
|
|
530
|
+
}
|
|
468
531
|
if (path.dirname(d) === d) break
|
|
469
532
|
}
|
|
470
533
|
// npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
|
|
@@ -499,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
499
562
|
bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
500
563
|
yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
501
564
|
}[manager][field]
|
|
502
|
-
const
|
|
565
|
+
const specs = [spec, ...(install.plugins ?? []).map((p) => `${p}@${version}`)]
|
|
566
|
+
const argv = manager === 'npm' ? ['npm', 'install', ...specs] : [manager, 'add', ...specs]
|
|
503
567
|
if (flag !== '') argv.push(flag)
|
|
504
568
|
// pnpm refuses an `add` at a workspace's root without saying it means the root.
|
|
505
569
|
if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
|
|
@@ -507,26 +571,40 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
507
571
|
return argv
|
|
508
572
|
}
|
|
509
573
|
|
|
510
|
-
/**
|
|
511
|
-
|
|
574
|
+
/**
|
|
575
|
+
* The version `tag` names, or the registry's latest: read for the "already
|
|
576
|
+
* at" check, and by `vx init`, which always installs the latest release.
|
|
577
|
+
*/
|
|
578
|
+
export async function targetVersion(tag: string | undefined, verb = 'vx upgrade'): Promise<string> {
|
|
512
579
|
const spec = versionSpec(tag)
|
|
513
580
|
if (spec !== 'latest') return spec
|
|
514
|
-
const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)
|
|
581
|
+
const v = ((await fetchManifest(PACKAGE, 'latest', verb)) as { version?: unknown } | null)
|
|
582
|
+
?.version
|
|
515
583
|
if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
|
|
516
|
-
throw new UserError(
|
|
584
|
+
throw new UserError(`${verb}: the npm registry named no latest version of ${PACKAGE}`)
|
|
517
585
|
}
|
|
518
586
|
return v
|
|
519
587
|
}
|
|
520
588
|
|
|
521
|
-
/**
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
589
|
+
/**
|
|
590
|
+
* Run the package manager that owns a vx at `current` to bring it to
|
|
591
|
+
* `version`; vx never installs one. `step` names the install when a run
|
|
592
|
+
* moves two (`project `, `global `).
|
|
593
|
+
*/
|
|
594
|
+
export async function upgradeManaged(
|
|
595
|
+
install: ManagedInstall,
|
|
596
|
+
version: string,
|
|
597
|
+
current = VERSION,
|
|
598
|
+
step = '',
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
const plugins = install.plugins ?? []
|
|
601
|
+
if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
|
|
602
|
+
ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
|
|
603
|
+
return
|
|
527
604
|
}
|
|
528
605
|
const argv = upgradeArgv(install, version)
|
|
529
|
-
|
|
606
|
+
// PATH as it is now: `Bun.which` alone reads the one the process started with.
|
|
607
|
+
const bin = Bun.which(argv[0]!, { PATH: process.env['PATH'] ?? '' })
|
|
530
608
|
if (bin === null) {
|
|
531
609
|
throw new UserError(
|
|
532
610
|
`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})`,
|
|
@@ -534,7 +612,11 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
534
612
|
}
|
|
535
613
|
ui.print(
|
|
536
614
|
'out',
|
|
537
|
-
ui.status(
|
|
615
|
+
ui.status(
|
|
616
|
+
'info',
|
|
617
|
+
'vx upgrade',
|
|
618
|
+
`${step}${version === current ? 'plugins' : current} → ${version}`,
|
|
619
|
+
),
|
|
538
620
|
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
539
621
|
)
|
|
540
622
|
const proc = Bun.spawnSync({
|
|
@@ -544,10 +626,100 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
544
626
|
})
|
|
545
627
|
if (proc.exitCode !== 0) {
|
|
546
628
|
throw new UserError(
|
|
547
|
-
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself
|
|
629
|
+
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself${
|
|
630
|
+
// The install is pinned, so pnpm refuses a release younger than
|
|
631
|
+
// its minimumReleaseAge instead of mixing releases; its words
|
|
632
|
+
// print above, so the way out is named (as vx-migrate does).
|
|
633
|
+
install.manager === 'pnpm'
|
|
634
|
+
? `; if pnpm refused a version published within its minimumReleaseAge, list ${[PACKAGE, ...plugins].join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
|
|
635
|
+
: ''
|
|
636
|
+
}`,
|
|
548
637
|
)
|
|
549
638
|
}
|
|
550
|
-
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Replace the binary at `dest`, at `current`, with the verified release
|
|
643
|
+
* `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
|
|
644
|
+
*/
|
|
645
|
+
async function upgradeBinary(
|
|
646
|
+
dest: string,
|
|
647
|
+
spec: string,
|
|
648
|
+
current = VERSION,
|
|
649
|
+
step = '',
|
|
650
|
+
key: SigningKey = NPM_SIGNING_KEY,
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
const pkg = platformPackage()
|
|
653
|
+
let installed: string | undefined
|
|
654
|
+
const replaced = await replaceBinary(
|
|
655
|
+
dest,
|
|
656
|
+
() =>
|
|
657
|
+
releaseBinary(
|
|
658
|
+
pkg,
|
|
659
|
+
spec,
|
|
660
|
+
(version) =>
|
|
661
|
+
ui.print(
|
|
662
|
+
'out',
|
|
663
|
+
ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
|
|
664
|
+
ui.text(ui.dim(dest)),
|
|
665
|
+
),
|
|
666
|
+
key,
|
|
667
|
+
current,
|
|
668
|
+
),
|
|
669
|
+
// The replaced binary's own version: the new build speaks for itself
|
|
670
|
+
// rather than this process guessing, and one that cannot is rolled back.
|
|
671
|
+
(bin) => {
|
|
672
|
+
installed = answeredVersion(bin)
|
|
673
|
+
return installed !== undefined
|
|
674
|
+
},
|
|
675
|
+
)
|
|
676
|
+
ui.print(
|
|
677
|
+
'out',
|
|
678
|
+
replaced
|
|
679
|
+
? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
|
|
680
|
+
: ui.status('info', 'vx upgrade', `${step}already at ${current}`),
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
|
|
685
|
+
function answeredVersion(bin: string): string | undefined {
|
|
686
|
+
return startedVersion(bin)?.split(' ')[1]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Bring `from`, the global vx that handed this run to the project's own
|
|
691
|
+
* (`VX_HANDED_OFF`), to `version`, the way it was installed: a binary
|
|
692
|
+
* installed by hand is replaced, a package manager's global install is
|
|
693
|
+
* updated by that manager. A refusal names it.
|
|
694
|
+
*/
|
|
695
|
+
export async function upgradeGlobal(
|
|
696
|
+
from: string,
|
|
697
|
+
version: string,
|
|
698
|
+
key: SigningKey = NPM_SIGNING_KEY,
|
|
699
|
+
): Promise<void> {
|
|
700
|
+
await named(`global vx ${from}`, async () => {
|
|
701
|
+
const current = answeredVersion(from)
|
|
702
|
+
if (current === undefined) {
|
|
703
|
+
throw new UserError('vx upgrade: it did not answer `--version`, so it is left as it is')
|
|
704
|
+
}
|
|
705
|
+
if (current === version) {
|
|
706
|
+
ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
const install = managedInstall(from, path.dirname(from))
|
|
710
|
+
if (install === null) await upgradeBinary(from, version, current, 'global ', key)
|
|
711
|
+
else await upgradeManaged(install, version, current, 'global ')
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
|
|
716
|
+
async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
717
|
+
try {
|
|
718
|
+
await run()
|
|
719
|
+
} catch (err) {
|
|
720
|
+
if (!(err instanceof UserError)) throw err
|
|
721
|
+
throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
|
|
722
|
+
}
|
|
551
723
|
}
|
|
552
724
|
|
|
553
725
|
export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
@@ -575,33 +747,21 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
575
747
|
}
|
|
576
748
|
const dest = process.execPath
|
|
577
749
|
const install = managedInstall(dest, process.cwd())
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
)
|
|
750
|
+
// A global vx handed this run to the project's own: both move, to one
|
|
751
|
+
// version, the project first (owner, 2026-10-15).
|
|
752
|
+
const from = process.env['VX_HANDED_OFF']
|
|
753
|
+
if (from !== undefined) {
|
|
754
|
+
const version = await targetVersion(tag)
|
|
755
|
+
await named('project vx', () =>
|
|
756
|
+
install === null
|
|
757
|
+
? upgradeBinary(dest, version, VERSION, 'project ')
|
|
758
|
+
: upgradeManaged(install, version, VERSION, 'project '),
|
|
759
|
+
)
|
|
760
|
+
await upgradeGlobal(from, version)
|
|
761
|
+
return 0
|
|
762
|
+
}
|
|
763
|
+
if (install !== null) await upgradeManaged(install, await targetVersion(tag))
|
|
764
|
+
else await upgradeBinary(dest, versionSpec(tag))
|
|
605
765
|
return 0
|
|
606
766
|
}
|
|
607
767
|
|
package/src/cli/watch-cycle.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
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 {
|
|
8
|
+
import { type RunOptions } from '../orchestrator/index.js'
|
|
9
|
+
import { createEventBus } from '../report/index.js'
|
|
9
10
|
|
|
10
11
|
export interface WatchCycle {
|
|
11
12
|
/** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// rules, git-ignored paths, the root arm's filter) and which files reshape
|
|
3
3
|
// the watched set. Pure decisions over paths; the loop acts on them.
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import {
|
|
7
8
|
executablePath,
|
|
@@ -369,12 +370,14 @@ export function gitSpeller(root: string): (p: string) => string {
|
|
|
369
370
|
* a project moved away whole is one event on its directory): what existed
|
|
370
371
|
* when watch armed, so a path born and gone since is told from a deletion
|
|
371
372
|
* (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
|
|
372
|
-
* so a server cannot be blamed for one.
|
|
373
|
+
* so a server cannot be blamed for one. `links` is the symlinks among the
|
|
374
|
+
* files: a key reads what each points to (cache/linked-inputs.ts), so watch
|
|
375
|
+
* arms that too. Undefined when git cannot answer:
|
|
373
376
|
* no inventory, every gone path is a deletion as before.
|
|
374
377
|
*/
|
|
375
378
|
export function gitFiles(
|
|
376
379
|
workspaceRoot: string,
|
|
377
|
-
): { listed: Set<string>; tracked: Set<string> } | undefined {
|
|
380
|
+
): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
|
|
378
381
|
let proc: ReturnType<typeof Bun.spawnSync>
|
|
379
382
|
try {
|
|
380
383
|
proc = Bun.spawnSync({
|
|
@@ -383,6 +386,7 @@ export function gitFiles(
|
|
|
383
386
|
'ls-files',
|
|
384
387
|
'-z',
|
|
385
388
|
'-t',
|
|
389
|
+
'-s',
|
|
386
390
|
'--cached',
|
|
387
391
|
'--others',
|
|
388
392
|
'--exclude-standard',
|
|
@@ -398,18 +402,30 @@ export function gitFiles(
|
|
|
398
402
|
if (proc.exitCode !== 0) return undefined
|
|
399
403
|
const files = new Set<string>()
|
|
400
404
|
const tracked = new Set<string>()
|
|
405
|
+
const links = new Set<string>()
|
|
401
406
|
for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
|
|
402
407
|
.decode(proc.stdout)
|
|
403
408
|
.split('\0')) {
|
|
404
409
|
if (entry.length === 0) continue
|
|
405
|
-
// `-t` prefixes a tag and a space; `?` is untracked.
|
|
406
|
-
|
|
410
|
+
// `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
|
|
411
|
+
// tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
|
|
412
|
+
const untracked = entry[0] === '?'
|
|
413
|
+
const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
|
|
407
414
|
// An untracked nested repository is listed as `dir/`.
|
|
408
415
|
let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
|
|
409
416
|
files.add(abs)
|
|
410
|
-
if (
|
|
417
|
+
if (!untracked) tracked.add(abs)
|
|
418
|
+
if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
|
|
411
419
|
for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
|
|
412
420
|
files.add(abs)
|
|
413
421
|
}
|
|
414
|
-
return { listed: files, tracked }
|
|
422
|
+
return { listed: files, tracked, links }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isLink(abs: string): boolean {
|
|
426
|
+
try {
|
|
427
|
+
return fs.lstatSync(abs).isSymbolicLink()
|
|
428
|
+
} catch {
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
415
431
|
}
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
+
import { filesBehind } from '../cache/index.js'
|
|
7
8
|
import { xxh3 } from '../util/index.js'
|
|
8
9
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
9
10
|
import { modifiedBefore } from './watch-fs.js'
|
|
@@ -13,9 +14,10 @@ const ABSENT = -1n
|
|
|
13
14
|
function settledState(abs: string): bigint {
|
|
14
15
|
let st: fs.Stats
|
|
15
16
|
try {
|
|
16
|
-
// A link is its target string, as the key folds
|
|
17
|
-
// a retarget to equal bytes as "the same"
|
|
18
|
-
if (fs.lstatSync(abs).isSymbolicLink())
|
|
17
|
+
// A link is its target string and what it points to, as the key folds
|
|
18
|
+
// it: following it alone saw a retarget to equal bytes as "the same".
|
|
19
|
+
if (fs.lstatSync(abs).isSymbolicLink())
|
|
20
|
+
return xxh3(`link:${fs.readlinkSync(abs)}\0${behindState(abs)}`)
|
|
19
21
|
st = fs.statSync(abs)
|
|
20
22
|
} catch {
|
|
21
23
|
return ABSENT
|
|
@@ -50,6 +52,34 @@ function settledState(abs: string): bigint {
|
|
|
50
52
|
return xxh3(Buffer.from(entries.join('\n')))
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* What a link points to, as change detection reads it: a file's bytes and
|
|
57
|
+
* mode, a directory's files by size and times (cache/linked-inputs.ts keys
|
|
58
|
+
* their bytes), nothing for a dangling link.
|
|
59
|
+
*/
|
|
60
|
+
function behindState(link: string): string {
|
|
61
|
+
let real: string
|
|
62
|
+
let st: fs.Stats
|
|
63
|
+
try {
|
|
64
|
+
real = fs.realpathSync(link)
|
|
65
|
+
st = fs.statSync(real)
|
|
66
|
+
} catch {
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
if (st.isFile()) return `${xxh3(fs.readFileSync(real))}:${st.mode & 0o7777}`
|
|
70
|
+
if (!st.isDirectory()) return ''
|
|
71
|
+
return filesBehind(real)
|
|
72
|
+
.map((rel) => {
|
|
73
|
+
try {
|
|
74
|
+
const f = fs.statSync(path.join(real, rel))
|
|
75
|
+
return `${rel}\0${f.size}\0${f.mtimeMs}\0${f.ctimeMs}`
|
|
76
|
+
} catch {
|
|
77
|
+
return rel
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/** What the judgement reads from the loop, which owns it and may replace it. */
|
|
54
84
|
export interface JudgeContext {
|
|
55
85
|
workspaceRoot: string
|
|
@@ -59,6 +89,8 @@ export interface JudgeContext {
|
|
|
59
89
|
held(): boolean
|
|
60
90
|
/** Projects whose uncached task may read a git-ignored file. */
|
|
61
91
|
uncached(): ReadonlySet<string>
|
|
92
|
+
/** Paths a task's inputs name outright: a git-ignored file at or under one is an input. */
|
|
93
|
+
namedIgnored?(): readonly string[]
|
|
62
94
|
/** Whether `abs`, under the project at `ownDir`, lies in a project nested there (`makeFence`). */
|
|
63
95
|
fenced?(ownDir: string, abs: string): boolean
|
|
64
96
|
/** The files git listed at the arm; absent when git could not answer. */
|
|
@@ -113,6 +145,15 @@ export class ChangeJudge {
|
|
|
113
145
|
|
|
114
146
|
constructor(private readonly ctx: JudgeContext) {}
|
|
115
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Record a path's state as it is, if this loop has not: a link armed for
|
|
150
|
+
* what it points to, whose own times say "before the arm" whatever
|
|
151
|
+
* changed behind it, so its first sighting compares against this.
|
|
152
|
+
*/
|
|
153
|
+
seed(abs: string): void {
|
|
154
|
+
if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
|
|
155
|
+
}
|
|
156
|
+
|
|
116
157
|
// A path this loop has never judged is a change only if it moved since
|
|
117
158
|
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
118
159
|
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
@@ -192,10 +233,13 @@ export class ChangeJudge {
|
|
|
192
233
|
// user wrote it; one the last cycle wrote (the pid file an uncached
|
|
193
234
|
// task rewrites every run, the loop the filter exists for) stays out,
|
|
194
235
|
// as does one in a project nested under it, which it does not own.
|
|
236
|
+
// So is one a task's inputs name outright (`.env.local`, `dist/**`).
|
|
195
237
|
const editForUncached = (p: string): boolean =>
|
|
196
|
-
[...this.ctx.uncached()].some(
|
|
238
|
+
([...this.ctx.uncached()].some(
|
|
197
239
|
(dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
|
|
198
|
-
)
|
|
240
|
+
) ||
|
|
241
|
+
(this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
|
|
242
|
+
!this.writtenDuringLastCycle(p)
|
|
199
243
|
// A path still there names the cycle before a gone one: an editor or
|
|
200
244
|
// `sed -i` saving through a temporary file fires that file first, and
|
|
201
245
|
// the cycle was announced by a name already renamed away.
|
package/src/cli/watch-set.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import type { ProjectConfig } from '../config.js'
|
|
8
|
+
import { namedOutright } from '../cache/index.js'
|
|
8
9
|
import {
|
|
9
10
|
buildPackageGraph,
|
|
10
11
|
lockKey,
|
|
@@ -64,6 +65,8 @@ export interface ConfigSweep {
|
|
|
64
65
|
inputs: Map<string, string[][]>
|
|
65
66
|
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
66
67
|
uncached: Set<string>
|
|
68
|
+
/** Paths a task's inputs name outright (`namedOutright`), absolute: a git-ignored one is an input. */
|
|
69
|
+
namedIgnored: string[]
|
|
67
70
|
/** Files the project configs import by relative path (item 949). */
|
|
68
71
|
configImports: string[]
|
|
69
72
|
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
@@ -115,6 +118,7 @@ export async function sweepConfigs(
|
|
|
115
118
|
addTo(outputs, dir, globs)
|
|
116
119
|
const workspaceInputs = new Set<string>()
|
|
117
120
|
const uncached = new Set<string>()
|
|
121
|
+
const namedIgnored = new Set<string>()
|
|
118
122
|
const folded: [string, ProjectConfig][] = []
|
|
119
123
|
const fold = (dir: string, config: ProjectConfig): void => {
|
|
120
124
|
folded.push([dir, config])
|
|
@@ -131,15 +135,20 @@ export async function sweepConfigs(
|
|
|
131
135
|
add(dir, task.cache?.outputs?.files)
|
|
132
136
|
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
133
137
|
if (names !== null && !names.has(name)) continue
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
addInputs(
|
|
139
|
-
workspaceRoot,
|
|
140
|
-
task.cache?.inputs?.workspaceFiles,
|
|
141
|
-
task.cache?.outputs?.workspaceFiles,
|
|
138
|
+
if (
|
|
139
|
+
task.inputs === undefined &&
|
|
140
|
+
task.exec !== undefined &&
|
|
141
|
+
task.exec.persistent === undefined
|
|
142
142
|
)
|
|
143
|
+
uncached.add(dir)
|
|
144
|
+
if (task.inputs !== undefined && task.exec !== undefined) {
|
|
145
|
+
const named = namedOutright(task)
|
|
146
|
+
for (const rel of named.files) namedIgnored.add(path.join(dir, rel))
|
|
147
|
+
for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
|
|
148
|
+
}
|
|
149
|
+
for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
150
|
+
addInputs(dir, task.inputs?.files, task.cache?.outputs?.files)
|
|
151
|
+
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.cache?.outputs?.workspaceFiles)
|
|
143
152
|
}
|
|
144
153
|
}
|
|
145
154
|
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|
|
@@ -148,6 +157,7 @@ export async function sweepConfigs(
|
|
|
148
157
|
outputs,
|
|
149
158
|
inputs,
|
|
150
159
|
uncached,
|
|
160
|
+
namedIgnored: [...namedIgnored],
|
|
151
161
|
configImports: [...projectImports],
|
|
152
162
|
workspaceConfigImports: [...wsImports],
|
|
153
163
|
staged,
|