@vzn/vx 0.0.639 → 0.0.641
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 +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -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/src/workspace/project-loader.ts +5 -4
package/src/cli/upgrade.ts
CHANGED
|
@@ -112,13 +112,18 @@ export function versionSpec(tag: string | undefined): string {
|
|
|
112
112
|
* as an internal error with a stack (a network namespace without a
|
|
113
113
|
* route, 2026-09-16). One line, the host and the reason.
|
|
114
114
|
*/
|
|
115
|
-
async function fetchOrRefuse(
|
|
115
|
+
async function fetchOrRefuse(
|
|
116
|
+
url: string,
|
|
117
|
+
init: RequestInit,
|
|
118
|
+
what: string,
|
|
119
|
+
verb = 'vx upgrade',
|
|
120
|
+
): Promise<Response> {
|
|
116
121
|
try {
|
|
117
122
|
return await fetch(url, init)
|
|
118
123
|
} catch (err) {
|
|
119
124
|
const message = err instanceof Error ? err.message : String(err)
|
|
120
125
|
throw new UserError(
|
|
121
|
-
|
|
126
|
+
`${verb}: could not reach ${new URL(url).host} to ${what} (${message}) — check the network or the proxy and re-run`,
|
|
122
127
|
)
|
|
123
128
|
}
|
|
124
129
|
}
|
|
@@ -130,33 +135,43 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
|
|
|
130
135
|
* connection was closed unexpectedly"), which `fetchOrRefuse` never sees
|
|
131
136
|
* and the CLI printed as an internal error with a stack.
|
|
132
137
|
*/
|
|
133
|
-
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> {
|
|
134
144
|
try {
|
|
135
145
|
return await read()
|
|
136
146
|
} catch (err) {
|
|
137
147
|
const message = err instanceof Error ? err.message : String(err)
|
|
138
148
|
throw new UserError(
|
|
139
|
-
|
|
149
|
+
`${verb}: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
|
|
140
150
|
)
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
/** The registry's document for `pkg@spec` (`latest` or a version). */
|
|
145
|
-
export async function fetchManifest(
|
|
155
|
+
export async function fetchManifest(
|
|
156
|
+
pkg: string,
|
|
157
|
+
spec: string,
|
|
158
|
+
verb = 'vx upgrade',
|
|
159
|
+
): Promise<unknown> {
|
|
146
160
|
const url = `${REGISTRY}/${pkg}/${spec}`
|
|
147
161
|
const res = await fetchOrRefuse(
|
|
148
162
|
url,
|
|
149
163
|
{ redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': `vx/${VERSION}` } },
|
|
150
164
|
'read the release',
|
|
165
|
+
verb,
|
|
151
166
|
)
|
|
152
167
|
if (!res.ok) {
|
|
153
168
|
throw new UserError(
|
|
154
|
-
|
|
169
|
+
`${verb}: could not read ${pkg}@${spec} from the npm registry (${res.status})${res.status === 404 && spec !== 'latest' ? ` — is ${spec} a published version?` : ''}`,
|
|
155
170
|
)
|
|
156
171
|
}
|
|
157
172
|
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
158
173
|
// page served with a 200) is the same refusal as a cut one.
|
|
159
|
-
return await readOrRefuse(() => res.json(), url, 'read the release')
|
|
174
|
+
return await readOrRefuse(() => res.json(), url, 'read the release', verb)
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
/** What a verified registry document names: the version, its tarball and the signed integrity. */
|
|
@@ -395,6 +410,8 @@ export interface ManagedInstall {
|
|
|
395
410
|
cwd: string
|
|
396
411
|
/** The project field that lists `@vzn/vx`. */
|
|
397
412
|
field?: 'dependencies' | 'devDependencies' | 'optionalDependencies'
|
|
413
|
+
/** The `@vzn/vx-*` plugins the same package.json lists from the registry, moved with core. */
|
|
414
|
+
plugins?: string[]
|
|
398
415
|
}
|
|
399
416
|
|
|
400
417
|
const FIELDS = ['devDependencies', 'dependencies', 'optionalDependencies'] as const
|
|
@@ -417,6 +434,45 @@ function listingField(dir: string): ManagedInstall['field'] | undefined {
|
|
|
417
434
|
})
|
|
418
435
|
}
|
|
419
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
|
+
|
|
420
476
|
/** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
|
|
421
477
|
function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
|
|
422
478
|
for (let d = dir; ; d = path.dirname(d)) {
|
|
@@ -463,12 +519,15 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
|
|
|
463
519
|
if (/\/yarn\/global\//.test(p)) return { manager: 'yarn', scope: 'global', cwd: root }
|
|
464
520
|
const field = listingField(root)
|
|
465
521
|
if (field !== undefined) {
|
|
466
|
-
|
|
522
|
+
const plugins = pluginsBeside(root, field)
|
|
523
|
+
return { manager: projectManager(root, execPath), scope: 'project', cwd: root, field, plugins }
|
|
467
524
|
}
|
|
468
525
|
for (let d = path.resolve(cwd); d.startsWith(root); d = path.dirname(d)) {
|
|
469
526
|
const f = listingField(d)
|
|
470
|
-
if (f !== undefined)
|
|
471
|
-
|
|
527
|
+
if (f !== undefined) {
|
|
528
|
+
const plugins = pluginsBeside(d, f)
|
|
529
|
+
return { manager: projectManager(d, execPath), scope: 'project', cwd: d, field: f, plugins }
|
|
530
|
+
}
|
|
472
531
|
if (path.dirname(d) === d) break
|
|
473
532
|
}
|
|
474
533
|
// npm's global tree: `<prefix>/lib/node_modules` (nvm, volta, Homebrew alike).
|
|
@@ -503,7 +562,8 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
503
562
|
bun: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
504
563
|
yarn: { devDependencies: '--dev', dependencies: '', optionalDependencies: '--optional' },
|
|
505
564
|
}[manager][field]
|
|
506
|
-
const
|
|
565
|
+
const specs = [spec, ...(install.plugins ?? []).map((p) => `${p}@${version}`)]
|
|
566
|
+
const argv = manager === 'npm' ? ['npm', 'install', ...specs] : [manager, 'add', ...specs]
|
|
507
567
|
if (flag !== '') argv.push(flag)
|
|
508
568
|
// pnpm refuses an `add` at a workspace's root without saying it means the root.
|
|
509
569
|
if (manager === 'pnpm' && existsSync(path.join(install.cwd, 'pnpm-workspace.yaml')))
|
|
@@ -511,13 +571,17 @@ export function upgradeArgv(install: ManagedInstall, version: string): string[]
|
|
|
511
571
|
return argv
|
|
512
572
|
}
|
|
513
573
|
|
|
514
|
-
/**
|
|
515
|
-
|
|
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> {
|
|
516
579
|
const spec = versionSpec(tag)
|
|
517
580
|
if (spec !== 'latest') return spec
|
|
518
|
-
const v = ((await fetchManifest(PACKAGE, 'latest')) as { version?: unknown } | null)
|
|
581
|
+
const v = ((await fetchManifest(PACKAGE, 'latest', verb)) as { version?: unknown } | null)
|
|
582
|
+
?.version
|
|
519
583
|
if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v)) {
|
|
520
|
-
throw new UserError(
|
|
584
|
+
throw new UserError(`${verb}: the npm registry named no latest version of ${PACKAGE}`)
|
|
521
585
|
}
|
|
522
586
|
return v
|
|
523
587
|
}
|
|
@@ -527,13 +591,14 @@ async function targetVersion(tag: string | undefined): Promise<string> {
|
|
|
527
591
|
* `version`; vx never installs one. `step` names the install when a run
|
|
528
592
|
* moves two (`project `, `global `).
|
|
529
593
|
*/
|
|
530
|
-
async function upgradeManaged(
|
|
594
|
+
export async function upgradeManaged(
|
|
531
595
|
install: ManagedInstall,
|
|
532
596
|
version: string,
|
|
533
597
|
current = VERSION,
|
|
534
598
|
step = '',
|
|
535
599
|
): Promise<void> {
|
|
536
|
-
|
|
600
|
+
const plugins = install.plugins ?? []
|
|
601
|
+
if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
|
|
537
602
|
ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
|
|
538
603
|
return
|
|
539
604
|
}
|
|
@@ -547,7 +612,11 @@ async function upgradeManaged(
|
|
|
547
612
|
}
|
|
548
613
|
ui.print(
|
|
549
614
|
'out',
|
|
550
|
-
ui.status(
|
|
615
|
+
ui.status(
|
|
616
|
+
'info',
|
|
617
|
+
'vx upgrade',
|
|
618
|
+
`${step}${version === current ? 'plugins' : current} → ${version}`,
|
|
619
|
+
),
|
|
551
620
|
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
552
621
|
)
|
|
553
622
|
const proc = Bun.spawnSync({
|
|
@@ -557,7 +626,14 @@ async function upgradeManaged(
|
|
|
557
626
|
})
|
|
558
627
|
if (proc.exitCode !== 0) {
|
|
559
628
|
throw new UserError(
|
|
560
|
-
`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
|
+
}`,
|
|
561
637
|
)
|
|
562
638
|
}
|
|
563
639
|
}
|
|
@@ -636,6 +712,31 @@ export async function upgradeGlobal(
|
|
|
636
712
|
})
|
|
637
713
|
}
|
|
638
714
|
|
|
715
|
+
/**
|
|
716
|
+
* `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
|
|
717
|
+
* else (owner, 2026-10-16) — no project's package.json, no plugin. The
|
|
718
|
+
* project's own vx refuses: no global ran.
|
|
719
|
+
*/
|
|
720
|
+
export async function upgradeOnlyGlobal(
|
|
721
|
+
self: string,
|
|
722
|
+
version: string,
|
|
723
|
+
current = VERSION,
|
|
724
|
+
): Promise<void> {
|
|
725
|
+
if (current === version) {
|
|
726
|
+
ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
// Read from the binary's own directory, not cwd: a project there is not this install's.
|
|
730
|
+
const install = managedInstall(self, path.dirname(self))
|
|
731
|
+
if (install?.scope === 'project') {
|
|
732
|
+
throw new UserError(
|
|
733
|
+
`vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
if (install === null) await upgradeBinary(self, version, current, 'global ')
|
|
737
|
+
else await upgradeManaged(install, version, current, 'global ')
|
|
738
|
+
}
|
|
739
|
+
|
|
639
740
|
/** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
|
|
640
741
|
async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
641
742
|
try {
|
|
@@ -646,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
|
646
747
|
}
|
|
647
748
|
}
|
|
648
749
|
|
|
649
|
-
export async function upgradeCmd(
|
|
750
|
+
export async function upgradeCmd(argv: readonly string[]): Promise<number> {
|
|
751
|
+
const global = argv.some((a) => a === '-g' || a === '--global')
|
|
752
|
+
const args = argv.filter((a) => a !== '-g' && a !== '--global')
|
|
650
753
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
651
754
|
if (unknown !== undefined) {
|
|
652
755
|
sayNotice(
|
|
@@ -670,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
670
773
|
)
|
|
671
774
|
}
|
|
672
775
|
const dest = process.execPath
|
|
776
|
+
const handedOffFrom = process.env['VX_HANDED_OFF']
|
|
777
|
+
if (global) {
|
|
778
|
+
// A launcher from before -g handed the call over: the global is the one that did.
|
|
779
|
+
const version = await targetVersion(tag)
|
|
780
|
+
if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
|
|
781
|
+
else await upgradeOnlyGlobal(dest, version)
|
|
782
|
+
return 0
|
|
783
|
+
}
|
|
673
784
|
const install = managedInstall(dest, process.cwd())
|
|
674
785
|
// A global vx handed this run to the project's own: both move, to one
|
|
675
786
|
// version, the project first (owner, 2026-10-15).
|
|
676
|
-
const from =
|
|
787
|
+
const from = handedOffFrom
|
|
677
788
|
if (from !== undefined) {
|
|
678
789
|
const version = await targetVersion(tag)
|
|
679
790
|
await named('project vx', () =>
|
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,
|
|
@@ -32,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
32
33
|
const segments = rel.split(path.sep)
|
|
33
34
|
for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
|
|
34
35
|
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
35
|
-
const name = segments[segments.length - 1]!
|
|
36
|
-
// A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
|
|
37
|
-
// renamed away before the judgement, it read as a deletion and ran one
|
|
38
|
-
// more cycle where an uncached task makes ignored paths count.
|
|
39
|
-
if (name.startsWith('.vx-tmp-')) return true
|
|
40
36
|
// `vx lock`'s review copy: no input, so a re-lock is no cycle.
|
|
41
|
-
return
|
|
37
|
+
return segments[segments.length - 1] === 'vx.lock.json'
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/**
|
|
@@ -369,12 +365,14 @@ export function gitSpeller(root: string): (p: string) => string {
|
|
|
369
365
|
* a project moved away whole is one event on its directory): what existed
|
|
370
366
|
* when watch armed, so a path born and gone since is told from a deletion
|
|
371
367
|
* (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
|
|
372
|
-
* so a server cannot be blamed for one.
|
|
368
|
+
* so a server cannot be blamed for one. `links` is the symlinks among the
|
|
369
|
+
* files: a key reads what each points to (cache/linked-inputs.ts), so watch
|
|
370
|
+
* arms that too. Undefined when git cannot answer:
|
|
373
371
|
* no inventory, every gone path is a deletion as before.
|
|
374
372
|
*/
|
|
375
373
|
export function gitFiles(
|
|
376
374
|
workspaceRoot: string,
|
|
377
|
-
): { listed: Set<string>; tracked: Set<string> } | undefined {
|
|
375
|
+
): { listed: Set<string>; tracked: Set<string>; links: Set<string> } | undefined {
|
|
378
376
|
let proc: ReturnType<typeof Bun.spawnSync>
|
|
379
377
|
try {
|
|
380
378
|
proc = Bun.spawnSync({
|
|
@@ -383,6 +381,7 @@ export function gitFiles(
|
|
|
383
381
|
'ls-files',
|
|
384
382
|
'-z',
|
|
385
383
|
'-t',
|
|
384
|
+
'-s',
|
|
386
385
|
'--cached',
|
|
387
386
|
'--others',
|
|
388
387
|
'--exclude-standard',
|
|
@@ -398,18 +397,30 @@ export function gitFiles(
|
|
|
398
397
|
if (proc.exitCode !== 0) return undefined
|
|
399
398
|
const files = new Set<string>()
|
|
400
399
|
const tracked = new Set<string>()
|
|
400
|
+
const links = new Set<string>()
|
|
401
401
|
for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
|
|
402
402
|
.decode(proc.stdout)
|
|
403
403
|
.split('\0')) {
|
|
404
404
|
if (entry.length === 0) continue
|
|
405
|
-
// `-t` prefixes a tag and a space; `?` is untracked.
|
|
406
|
-
|
|
405
|
+
// `-t` prefixes a tag and a space; `?` is untracked, a bare path. A
|
|
406
|
+
// tracked entry is `-s`'s `<mode> <oid> <stage>\t<path>` after the tag.
|
|
407
|
+
const untracked = entry[0] === '?'
|
|
408
|
+
const p = untracked ? entry.slice(2) : entry.slice(entry.indexOf('\t') + 1)
|
|
407
409
|
// An untracked nested repository is listed as `dir/`.
|
|
408
410
|
let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
|
|
409
411
|
files.add(abs)
|
|
410
|
-
if (
|
|
412
|
+
if (!untracked) tracked.add(abs)
|
|
413
|
+
if (untracked ? isLink(abs) : entry.startsWith('120000 ', 2)) links.add(abs)
|
|
411
414
|
for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
|
|
412
415
|
files.add(abs)
|
|
413
416
|
}
|
|
414
|
-
return { listed: files, tracked }
|
|
417
|
+
return { listed: files, tracked, links }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isLink(abs: string): boolean {
|
|
421
|
+
try {
|
|
422
|
+
return fs.lstatSync(abs).isSymbolicLink()
|
|
423
|
+
} catch {
|
|
424
|
+
return false
|
|
425
|
+
}
|
|
415
426
|
}
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
|
|
|
16
16
|
export function isIgnoredDir(segments: readonly string[]): boolean {
|
|
17
17
|
const last = segments.length - 1
|
|
18
18
|
if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
|
|
19
|
-
|
|
19
|
+
// vx's cache, and where its restores stage (archive.ts).
|
|
20
|
+
return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
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,
|