@vutex/vutex 0.0.14 → 0.0.16

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.
@@ -1,18 +1,143 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
4
 
4
5
  import TOML from "@iarna/toml";
5
6
 
6
- import type { PackageDoctorReport, PackageInstallOptions, PackageRecord } from "../types.js";
7
+ import type {
8
+ ActivatedPackage,
9
+ PackageCheck,
10
+ PackageDoctorReport,
11
+ PackageInstallOptions,
12
+ PackageRecord,
13
+ VuTeXDiagnostic,
14
+ } from "../types.js";
15
+ import { lookupBuiltin } from "./builtin.js";
7
16
  import { readLockfile, writeLockfile, type LockedPackage } from "./lockfile.js";
8
- import { readManifest } from "./manifest.js";
17
+ import { inferSlot, isPackageSlot, readManifest, type VuTeXManifest } from "./manifest.js";
18
+ import {
19
+ downloadRegistryFile,
20
+ fetchRegistryMetadata,
21
+ nodeTransport,
22
+ parseRegistrySpec,
23
+ satisfiesRange,
24
+ selectVersion,
25
+ type RegistryFile,
26
+ type RegistryMetadata,
27
+ type RegistryTransport,
28
+ type RegistryVersion,
29
+ } from "./registry.js";
9
30
 
10
- function hashPlaceholder(name: string, version: string): string {
11
- return `sha256:scaffold:${name}@${version}`;
31
+ export type ResolvedPackage = Omit<LockedPackage, "contentHash"> & {
32
+ /** Files a registry version downloads, absent for builtin and path sources. */
33
+ registryFiles?: RegistryFile[];
34
+ /** Metadata URL that named the selected version. */
35
+ metadataUrl?: string;
36
+ };
37
+
38
+ export type PackageManagerOptions = {
39
+ /** Network client, injectable so tests never reach a real registry. */
40
+ transport?: RegistryTransport;
41
+ /** Registry base URL that wins over `VUTEX_REGISTRY`. */
42
+ registry?: string;
43
+ /** Environment to read `VUTEX_REGISTRY` from. */
44
+ environment?: NodeJS.ProcessEnv;
45
+ };
46
+
47
+ export class PackageResolveError extends Error {
48
+ constructor(readonly diagnostics: VuTeXDiagnostic[]) {
49
+ super(diagnostics.map((item) => `${item.code}: ${item.message}`).join(" | "));
50
+ this.name = "PackageResolveError";
51
+ }
52
+ }
53
+
54
+ function packageDiagnostic(code: string, message: string, args?: Record<string, unknown>): VuTeXDiagnostic {
55
+ return { code, severity: "error", message, stage: "package", args };
56
+ }
57
+
58
+ function canonicalRecord(entry: Omit<LockedPackage, "contentHash">): string {
59
+ return [
60
+ entry.slot,
61
+ entry.name,
62
+ entry.version,
63
+ entry.source,
64
+ [...entry.dependencies].sort().join(","),
65
+ ].join("\u0000");
66
+ }
67
+
68
+ /** SHA-256 of the canonical package record, not of the resource bytes. */
69
+ export function contentDigest(entry: Omit<LockedPackage, "contentHash">): string {
70
+ return `sha256:${createHash("sha256").update(canonicalRecord(entry)).digest("hex")}`;
71
+ }
72
+
73
+ function isPathSource(value: string): boolean {
74
+ return (
75
+ value.startsWith("file:") ||
76
+ value.startsWith("./") ||
77
+ value.startsWith("../") ||
78
+ value.startsWith("/") ||
79
+ value.startsWith("\\") ||
80
+ /^[A-Za-z]:[\\/]/u.test(value)
81
+ );
82
+ }
83
+
84
+ function bareName(value: string): string {
85
+ return value.startsWith("@vutex/") ? value.slice("@vutex/".length) : value;
86
+ }
87
+
88
+ async function hashTree(root: string): Promise<{ artifactHash: string; files: number }> {
89
+ const entries: string[] = [];
90
+ async function walk(dir: string, prefix: string): Promise<void> {
91
+ for (const item of await readdir(dir, { withFileTypes: true })) {
92
+ const name = prefix.length > 0 ? `${prefix}/${item.name}` : item.name;
93
+ if (item.isSymbolicLink()) {
94
+ throw new Error(`symbolic links are not supported in resource packages: ${name}`);
95
+ }
96
+ if (item.isDirectory()) {
97
+ await walk(join(dir, item.name), name);
98
+ } else if (item.isFile()) {
99
+ const digest = createHash("sha256")
100
+ .update(await readFile(join(dir, item.name)))
101
+ .digest("hex");
102
+ entries.push(`${name}\u0000${digest}`);
103
+ }
104
+ }
105
+ }
106
+ await walk(root, "");
107
+ const lines = entries.sort((left, right) => left.localeCompare(right, "en")).join("\n");
108
+ return {
109
+ artifactHash: `sha256:${createHash("sha256").update(lines).digest("hex")}`,
110
+ files: entries.length,
111
+ };
112
+ }
113
+
114
+ function failedCheck(id: string, code: string, message: string): PackageCheck {
115
+ return { id, ok: false, code, message };
116
+ }
117
+
118
+ function passedCheck(id: string, message: string): PackageCheck {
119
+ return { id, ok: true, message };
12
120
  }
13
121
 
14
122
  export class PackageManager {
15
- constructor(private readonly projectRoot: string) {}
123
+ constructor(
124
+ private readonly projectRoot: string,
125
+ private readonly options: PackageManagerOptions = {},
126
+ ) {}
127
+
128
+ /** Registry base URL: option, then manifest `[registry] url`, then env. */
129
+ private async registryBase(): Promise<string | undefined> {
130
+ const manifest = await readManifest(this.projectRoot);
131
+ const environment = this.options.environment ?? process.env;
132
+ const candidate = this.options.registry ?? manifest?.registry?.url ?? environment.VUTEX_REGISTRY;
133
+ return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : undefined;
134
+ }
135
+
136
+ /** Content addressed cache path for one downloaded registry package. */
137
+ private registryCachePath(slot: string, name: string, artifactHash: string): string {
138
+ const digest = artifactHash.startsWith("sha256:") ? artifactHash.slice("sha256:".length) : artifactHash;
139
+ return join(this.projectRoot, ".vutex", "packages", slot, encodeURIComponent(name), digest);
140
+ }
16
141
 
17
142
  async list(): Promise<PackageRecord[]> {
18
143
  const lock = await readLockfile(this.projectRoot);
@@ -21,29 +146,39 @@ export class PackageManager {
21
146
  if (!manifest?.packages) {
22
147
  return [];
23
148
  }
24
- return Object.entries(manifest.packages).map(([alias, version]) => ({
25
- name: alias,
26
- version,
27
- source: "manifest",
28
- }));
149
+ return Object.entries(manifest.packages)
150
+ .sort(([left], [right]) => left.localeCompare(right, "en"))
151
+ .map(([slot, name]) => ({ slot, name, version: "unresolved", source: "manifest" }));
29
152
  }
30
- return lock.packages.map((item) => ({
31
- name: item.name,
32
- version: item.version,
33
- source: item.source,
34
- }));
153
+ return [...lock.packages]
154
+ .sort((left, right) =>
155
+ left.slot === right.slot
156
+ ? left.name.localeCompare(right.name, "en")
157
+ : left.slot.localeCompare(right.slot, "en"),
158
+ )
159
+ .map((item) => ({
160
+ slot: item.slot,
161
+ name: item.name,
162
+ version: item.version,
163
+ source: item.source,
164
+ artifactHash: item.artifactHash,
165
+ }));
35
166
  }
36
167
 
37
168
  async add(name: string, options: PackageInstallOptions = {}): Promise<void> {
169
+ const slot = options.slot ?? inferSlot(name);
170
+ if (slot === null || !isPackageSlot(slot)) {
171
+ throw new PackageResolveError([
172
+ packageDiagnostic(
173
+ "VUTEX_PACKAGE_SLOT_REQUIRED",
174
+ `cannot infer a slot for ${name}, pass --slot <slot>`,
175
+ ),
176
+ ]);
177
+ }
38
178
  const manifest = (await readManifest(this.projectRoot)) ?? {};
39
179
  const packages = { ...(manifest.packages ?? {}) };
40
- const alias = name.includes("/") ? name.split("/").pop() ?? name : name;
41
- packages[alias] = options.version ?? "latest";
42
- const next = {
43
- ...manifest,
44
- packages,
45
- };
46
- await writeFile(join(this.projectRoot, "vutex.toml"), `${TOML.stringify(next)}\n`, "utf8");
180
+ packages[slot] = name;
181
+ await this.writeManifest({ ...manifest, packages });
47
182
  }
48
183
 
49
184
  async remove(name: string): Promise<void> {
@@ -52,68 +187,994 @@ export class PackageManager {
52
187
  return;
53
188
  }
54
189
  const packages = { ...manifest.packages };
55
- delete packages[name];
56
- const next = { ...manifest, packages };
57
- await writeFile(join(this.projectRoot, "vutex.toml"), `${TOML.stringify(next)}\n`, "utf8");
190
+ const bare = bareName(name);
191
+ let removed = false;
192
+ if (name in packages) {
193
+ delete packages[name];
194
+ removed = true;
195
+ }
196
+ for (const [slot, value] of Object.entries(packages)) {
197
+ if (bareName(value) === bare) {
198
+ delete packages[slot];
199
+ removed = true;
200
+ }
201
+ }
202
+ if (removed) {
203
+ await this.writeManifest({ ...manifest, packages });
204
+ }
58
205
  }
59
206
 
60
207
  async install(options: PackageInstallOptions = {}): Promise<void> {
61
- const manifest = await readManifest(this.projectRoot);
62
- const cacheDir = join(this.projectRoot, ".vutex", "packages");
63
- await mkdir(cacheDir, { recursive: true });
208
+ await this.resolveAndInstall(options, "install");
209
+ }
210
+
211
+ private async resolveAndInstall(
212
+ options: PackageInstallOptions,
213
+ mode: "install" | "update",
214
+ ): Promise<void> {
215
+ const declared = await this.declaredPackages(options);
216
+ const plan = await this.resolvePlan(declared, mode);
217
+ const errors = plan.diagnostics.filter((item) => item.severity === "error");
218
+ if (errors.length > 0) {
219
+ throw new PackageResolveError(errors);
220
+ }
64
221
 
222
+ await mkdir(join(this.projectRoot, ".vutex", "packages"), { recursive: true });
223
+ const failures: VuTeXDiagnostic[] = [];
65
224
  const entries: LockedPackage[] = [];
66
- const sourcePackages = manifest?.packages ?? {};
67
- if (options.name) {
68
- sourcePackages[options.name] = options.version ?? "latest";
225
+ for (const item of plan.packages) {
226
+ let artifactHash = item.artifactHash;
227
+ if (item.source.startsWith("registry+")) {
228
+ // A package already present in the content addressed cache is
229
+ // not downloaded again, so an install stays offline once the
230
+ // lock has been satisfied.
231
+ if (await this.cachedRegistryArtifact(item)) {
232
+ artifactHash = item.artifactHash;
233
+ } else {
234
+ let downloadable = item;
235
+ if (item.registryFiles === undefined) {
236
+ const hydrated = await this.hydratePinnedPackage(item);
237
+ if (hydrated.diagnostic) {
238
+ failures.push(hydrated.diagnostic);
239
+ continue;
240
+ }
241
+ downloadable = hydrated.package as ResolvedPackage;
242
+ }
243
+ const downloaded = await this.downloadRegistryPackage(downloadable);
244
+ if (downloaded.diagnostic) {
245
+ failures.push(downloaded.diagnostic);
246
+ continue;
247
+ }
248
+ artifactHash = downloaded.artifactHash;
249
+ }
250
+ }
251
+ const entry: Omit<LockedPackage, "contentHash"> = {
252
+ slot: item.slot,
253
+ name: item.name,
254
+ version: item.version,
255
+ source: item.source,
256
+ artifactHash,
257
+ dependencies: item.dependencies,
258
+ };
259
+ entries.push({ ...entry, contentHash: contentDigest(entry) });
260
+ }
261
+ if (failures.length > 0) {
262
+ throw new PackageResolveError(failures);
69
263
  }
264
+ await writeLockfile(this.projectRoot, { version: 1, packages: entries });
265
+ }
70
266
 
71
- for (const [alias, version] of Object.entries(sourcePackages)) {
72
- entries.push({
73
- name: alias,
74
- version,
75
- source: `@vutex/${alias}`,
76
- contentHash: hashPlaceholder(alias, version),
77
- dependencies: [],
78
- });
267
+ /** Whether the content addressed cache already holds this exact artifact. */
268
+ private async cachedRegistryArtifact(item: ResolvedPackage): Promise<boolean> {
269
+ if (item.artifactHash === undefined) {
270
+ return false;
271
+ }
272
+ const cache = this.registryCachePath(item.slot, item.name, item.artifactHash);
273
+ try {
274
+ const tree = await hashTree(cache);
275
+ return tree.artifactHash === item.artifactHash;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * A locked registry package whose cache went missing still knows its exact
283
+ * version. Fetch that version's metadata so the files can be downloaded
284
+ * without letting a newer version replace the pin.
285
+ */
286
+ private async hydratePinnedPackage(
287
+ item: ResolvedPackage,
288
+ ): Promise<{ package?: ResolvedPackage; diagnostic?: VuTeXDiagnostic }> {
289
+ const base = await this.registryBase();
290
+ if (base === undefined) {
291
+ return {
292
+ diagnostic: packageDiagnostic(
293
+ "VUTEX_PACKAGE_REGISTRY_UNCONFIGURED",
294
+ `no registry is configured to restore ${item.slot} ${item.name}`,
295
+ { slot: item.slot, name: item.name },
296
+ ),
297
+ };
298
+ }
299
+ const fetched = await fetchRegistryMetadata(this.options.transport ?? nodeTransport, base, item.slot, item.name);
300
+ if (fetched.diagnostic || fetched.metadata === undefined) {
301
+ return { diagnostic: fetched.diagnostic };
302
+ }
303
+ const version = fetched.metadata.versions.find((entry) => entry.version === item.version);
304
+ if (version === undefined) {
305
+ return {
306
+ diagnostic: packageDiagnostic(
307
+ "VUTEX_PACKAGE_VERSION_NO_MATCH",
308
+ `${item.slot} ${item.name}@${item.version} is locked but no longer published`,
309
+ { slot: item.slot, name: item.name, version: item.version },
310
+ ),
311
+ };
312
+ }
313
+ return {
314
+ package: this.registryPackage(item.slot, item.name, version, base, fetched.metadataUrl),
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Download one registry version into a staging tree and move it into the
320
+ * content addressed cache.
321
+ *
322
+ * The cache is never mutated in place: a staged tree either matches the
323
+ * address already present or lands under a fresh one, so a failed download
324
+ * cannot poison a later activation.
325
+ */
326
+ private async downloadRegistryPackage(
327
+ item: ResolvedPackage,
328
+ ): Promise<{ artifactHash?: string; diagnostic?: VuTeXDiagnostic }> {
329
+ const files = item.registryFiles ?? [];
330
+ if (files.length === 0) {
331
+ return { artifactHash: item.artifactHash };
332
+ }
333
+ const staging = join(this.projectRoot, ".vutex", "staging", randomUUID());
334
+ await mkdir(staging, { recursive: true });
335
+ const transport = this.options.transport ?? nodeTransport;
336
+
337
+ for (const file of files) {
338
+ const target = resolve(staging, file.path);
339
+ const relativePath = relative(staging, target);
340
+ if (relativePath.length === 0 || relativePath.startsWith("..") || isAbsolute(relativePath)) {
341
+ return {
342
+ diagnostic: packageDiagnostic(
343
+ "VUTEX_PACKAGE_METADATA_INVALID",
344
+ `package file escapes its root: ${file.path}`,
345
+ { path: file.path },
346
+ ),
347
+ };
348
+ }
349
+ let url: string;
350
+ try {
351
+ url = new URL(file.url, item.metadataUrl).toString();
352
+ } catch {
353
+ return {
354
+ diagnostic: packageDiagnostic(
355
+ "VUTEX_PACKAGE_METADATA_INVALID",
356
+ `package file url is invalid: ${file.url}`,
357
+ { path: file.path, url: file.url },
358
+ ),
359
+ };
360
+ }
361
+ const downloaded = await downloadRegistryFile(transport, url, file.path);
362
+ if (downloaded.diagnostic || downloaded.bytes === undefined) {
363
+ return { diagnostic: downloaded.diagnostic };
364
+ }
365
+ const bytes = downloaded.bytes;
366
+ if (file.sha256 !== undefined) {
367
+ const expected = file.sha256.startsWith("sha256:") ? file.sha256.slice("sha256:".length) : file.sha256;
368
+ const actual = createHash("sha256").update(bytes).digest("hex");
369
+ if (actual !== expected.toLowerCase()) {
370
+ return {
371
+ diagnostic: packageDiagnostic(
372
+ "VUTEX_PACKAGE_FILE_HASH_MISMATCH",
373
+ `downloaded ${file.path} does not match its sha256`,
374
+ { path: file.path, url, expected, actual },
375
+ ),
376
+ };
377
+ }
378
+ }
379
+ await mkdir(dirname(target), { recursive: true });
380
+ await writeFile(target, bytes);
79
381
  }
80
382
 
81
- await writeLockfile(this.projectRoot, {
82
- version: 1,
83
- packages: entries,
84
- });
383
+ const tree = await hashTree(staging);
384
+ const computed = tree.files > 0 ? tree.artifactHash : undefined;
385
+ if (item.artifactHash !== undefined && computed !== item.artifactHash) {
386
+ return {
387
+ diagnostic: packageDiagnostic(
388
+ "VUTEX_PACKAGE_ARTIFACT_MISMATCH",
389
+ `${item.slot} ${item.name} tree does not match the registry artifact hash`,
390
+ { slot: item.slot, expected: item.artifactHash, actual: computed },
391
+ ),
392
+ };
393
+ }
394
+ const artifactHash = computed ?? item.artifactHash;
395
+ if (artifactHash === undefined || computed === undefined) {
396
+ return { artifactHash };
397
+ }
398
+ const cache = this.registryCachePath(item.slot, item.name, artifactHash);
399
+ try {
400
+ const existing = await hashTree(cache);
401
+ if (existing.artifactHash === artifactHash) {
402
+ return { artifactHash };
403
+ }
404
+ } catch {
405
+ // The cache is absent, so the staged tree can take the address.
406
+ }
407
+ await mkdir(dirname(cache), { recursive: true });
408
+ try {
409
+ await rename(staging, cache);
410
+ } catch (error) {
411
+ return {
412
+ diagnostic: packageDiagnostic(
413
+ "VUTEX_PACKAGE_CACHE_CORRUPT",
414
+ `${item.slot} cache is occupied by a different tree: ${error instanceof Error ? error.message : String(error)}`,
415
+ { slot: item.slot, cache },
416
+ ),
417
+ };
418
+ }
419
+ return { artifactHash };
85
420
  }
86
421
 
87
422
  async update(): Promise<void> {
88
- await this.install();
423
+ await this.resolveAndInstall({}, "update");
89
424
  }
90
425
 
91
426
  async doctor(): Promise<PackageDoctorReport> {
92
- const checks: PackageDoctorReport["checks"] = [];
427
+ const checks: PackageCheck[] = [];
428
+ const manifest = await readManifest(this.projectRoot);
429
+ const lock = await readLockfile(this.projectRoot);
430
+
431
+ checks.push(
432
+ manifest
433
+ ? passedCheck("manifest", "vutex.toml present")
434
+ : failedCheck("manifest", "VUTEX_MANIFEST_MISSING", "vutex.toml is missing"),
435
+ );
436
+
437
+ const declared = manifest?.packages ?? {};
438
+ const declaredSlots = Object.keys(declared).sort((left, right) => left.localeCompare(right, "en"));
439
+ // Doctor resolves as if updating, so the drift check still sees what
440
+ // the declared ranges would pick today.
441
+ const plan = await this.resolvePlan(declared, "update");
442
+
443
+ const unknownSlots = plan.diagnostics.filter((item) => item.code === "VUTEX_PACKAGE_SLOT_UNKNOWN");
444
+ checks.push(
445
+ unknownSlots.length === 0
446
+ ? passedCheck("slots", "every package slot is known")
447
+ : failedCheck("slots", "VUTEX_PACKAGE_SLOT_UNKNOWN", unknownSlots.map((item) => item.message).join(" | ")),
448
+ );
449
+
450
+ const sourceIssues = plan.diagnostics.filter(
451
+ (item) =>
452
+ item.severity === "error" &&
453
+ item.code !== "VUTEX_PACKAGE_SLOT_UNKNOWN" &&
454
+ item.code !== "VUTEX_PACKAGE_FONT_MISSING",
455
+ );
456
+ checks.push(
457
+ sourceIssues.length === 0
458
+ ? passedCheck("sources", "every package source resolves")
459
+ : failedCheck("sources", sourceIssues[0].code ?? "VUTEX_PACKAGE_NOT_FOUND", sourceIssues.map((item) => item.message).join(" | ")),
460
+ );
461
+
462
+ const fontIssues = plan.diagnostics.filter((item) => item.code === "VUTEX_PACKAGE_FONT_MISSING");
463
+ checks.push(
464
+ fontIssues.length === 0
465
+ ? passedCheck("fonts", "every declared font resolves")
466
+ : failedCheck("fonts", "VUTEX_PACKAGE_FONT_MISSING", fontIssues.map((item) => item.message).join(" | ")),
467
+ );
468
+
469
+ if (declaredSlots.length === 0) {
470
+ checks.push(passedCheck("lockfile", "no resource packages declared"));
471
+ } else if (!lock) {
472
+ checks.push(failedCheck("lockfile", "VUTEX_LOCK_MISSING", "vutex.lock is missing"));
473
+ } else {
474
+ checks.push(passedCheck("lockfile", `vutex.lock tracks ${lock.packages.length} package(s)`));
475
+ }
476
+
477
+ if (declaredSlots.length > 0 && lock) {
478
+ checks.push(...this.checkActivation(declared, plan.packages, lock.packages));
479
+ checks.push(...this.checkIntegrity(lock.packages));
480
+ checks.push(...this.checkArtifacts(plan.packages, lock.packages));
481
+ checks.push(...(await this.checkCache(lock.packages)));
482
+ }
483
+ checks.push(...this.checkProfile(manifest?.profile?.name, declared));
484
+
485
+ return { ok: checks.every((check) => check.ok), checks };
486
+ }
487
+
488
+ /**
489
+ * Resolve the packages activated for a render.
490
+ *
491
+ * Error diagnostics mean the render must not silently proceed with a
492
+ * missing, stale, or corrupted resource.
493
+ */
494
+ async activation(): Promise<{ packages: ActivatedPackage[]; diagnostics: VuTeXDiagnostic[] }> {
93
495
  const manifest = await readManifest(this.projectRoot);
496
+ const declared = manifest?.packages ?? {};
497
+ const declaredSlots = Object.keys(declared).sort((left, right) => left.localeCompare(right, "en"));
498
+ if (declaredSlots.length === 0) {
499
+ return { packages: [], diagnostics: [] };
500
+ }
501
+
502
+ const plan = await this.resolvePlan(declared, "activate");
503
+ const diagnostics: VuTeXDiagnostic[] = [...plan.diagnostics];
94
504
  const lock = await readLockfile(this.projectRoot);
505
+ if (!lock) {
506
+ diagnostics.push(
507
+ packageDiagnostic("VUTEX_PACKAGE_UNACTIVATED", "vutex.lock is missing, run `vutex package install`"),
508
+ );
509
+ return { packages: [], diagnostics };
510
+ }
511
+
512
+ const lockedBySlot = new Map(lock.packages.map((item) => [item.slot, item]));
513
+ const packages: ActivatedPackage[] = [];
514
+ for (const slot of declaredSlots) {
515
+ const resolved = plan.packages.find((item) => item.slot === slot);
516
+ const locked = lockedBySlot.get(slot);
517
+ if (!locked) {
518
+ diagnostics.push(
519
+ packageDiagnostic("VUTEX_PACKAGE_UNACTIVATED", `${slot} is declared but not locked`),
520
+ );
521
+ continue;
522
+ }
523
+ if (resolved && locked.name !== resolved.name) {
524
+ diagnostics.push(
525
+ packageDiagnostic(
526
+ "VUTEX_PACKAGE_UNACTIVATED",
527
+ `${slot} is locked to ${locked.name} but the manifest declares ${resolved.name}`,
528
+ ),
529
+ );
530
+ continue;
531
+ }
532
+ if (resolved && locked.version !== resolved.version) {
533
+ diagnostics.push(
534
+ packageDiagnostic(
535
+ "VUTEX_PACKAGE_VERSION_DRIFT",
536
+ `${slot} is locked at ${locked.version} but resolves to ${resolved.version}`,
537
+ ),
538
+ );
539
+ continue;
540
+ }
541
+ if (resolved && locked.artifactHash !== resolved.artifactHash) {
542
+ diagnostics.push(
543
+ packageDiagnostic(
544
+ "VUTEX_PACKAGE_ARTIFACT_MISMATCH",
545
+ `${slot} artifact hash does not match ${locked.name}`,
546
+ ),
547
+ );
548
+ continue;
549
+ }
550
+ if (contentDigest(locked) !== locked.contentHash) {
551
+ diagnostics.push(
552
+ packageDiagnostic(
553
+ "VUTEX_PACKAGE_HASH_MISMATCH",
554
+ `${slot} content hash does not match its record`,
555
+ ),
556
+ );
557
+ continue;
558
+ }
559
+ // Registry packages are activated from the content addressed cache.
560
+ // A render never reaches the network and never trusts the lock alone.
561
+ if (locked.source.startsWith("registry+") && locked.artifactHash !== undefined) {
562
+ const cache = this.registryCachePath(slot, locked.name, locked.artifactHash);
563
+ let tree;
564
+ try {
565
+ tree = await hashTree(cache);
566
+ } catch {
567
+ diagnostics.push(
568
+ packageDiagnostic(
569
+ "VUTEX_PACKAGE_ARTIFACT_MISSING",
570
+ `${slot} is locked but not downloaded, run \`vutex package install\``,
571
+ { slot, name: locked.name, version: locked.version },
572
+ ),
573
+ );
574
+ continue;
575
+ }
576
+ if (tree.artifactHash !== locked.artifactHash) {
577
+ diagnostics.push(
578
+ packageDiagnostic(
579
+ "VUTEX_PACKAGE_ARTIFACT_MISMATCH",
580
+ `${slot} cache does not match ${locked.name}`,
581
+ { slot, expected: locked.artifactHash, actual: tree.artifactHash },
582
+ ),
583
+ );
584
+ continue;
585
+ }
586
+ }
587
+ const directory =
588
+ locked.source === "path"
589
+ ? resolve(this.projectRoot, locked.name)
590
+ : locked.source.startsWith("registry+") && locked.artifactHash !== undefined
591
+ ? this.registryCachePath(slot, locked.name, locked.artifactHash)
592
+ : undefined;
593
+ packages.push({
594
+ slot,
595
+ name: locked.name,
596
+ version: locked.version,
597
+ source: locked.source,
598
+ artifactHash: locked.artifactHash,
599
+ path: directory,
600
+ });
601
+ diagnostics.push({
602
+ code: "VUTEX_PACKAGE_ACTIVATED",
603
+ severity: "info",
604
+ message: `${slot} activates ${locked.name}`,
605
+ stage: "package",
606
+ args: { slot, name: locked.name, version: locked.version, source: locked.source },
607
+ });
608
+ }
609
+ return { packages, diagnostics };
610
+ }
95
611
 
96
- checks.push({
97
- id: "manifest",
98
- ok: manifest !== null,
99
- message: manifest ? "vutex.toml present" : "vutex.toml missing (optional for scaffold)",
100
- });
101
-
102
- if (manifest?.packages && Object.keys(manifest.packages).length > 0) {
103
- checks.push({
104
- id: "lockfile",
105
- ok: lock !== null && lock.packages.length > 0,
106
- message: lock ? `vutex.lock tracks ${lock.packages.length} package(s)` : "vutex.lock missing",
612
+ private async declaredPackages(options: PackageInstallOptions): Promise<Record<string, string>> {
613
+ const manifest = await readManifest(this.projectRoot);
614
+ const declared: Record<string, string> = { ...(manifest?.packages ?? {}) };
615
+ if (options.name) {
616
+ const slot = options.slot ?? inferSlot(options.name);
617
+ if (slot === null || !isPackageSlot(slot)) {
618
+ throw new PackageResolveError([
619
+ packageDiagnostic(
620
+ "VUTEX_PACKAGE_SLOT_REQUIRED",
621
+ `cannot infer a slot for ${options.name}, pass --slot <slot>`,
622
+ ),
623
+ ]);
624
+ }
625
+ declared[slot] = options.name;
626
+ }
627
+ return declared;
628
+ }
629
+
630
+ private async resolvePlan(
631
+ declared: Record<string, string>,
632
+ mode: "install" | "update" | "activate" = "install",
633
+ ): Promise<{ packages: ResolvedPackage[]; diagnostics: VuTeXDiagnostic[] }> {
634
+ const diagnostics: VuTeXDiagnostic[] = [];
635
+ const packages = new Map<string, ResolvedPackage>();
636
+ const rangesBySlot = new Map<string, string[]>();
637
+ const metadataByKey = new Map<string, RegistryMetadata>();
638
+ const metadataUrls = new Map<string, string | undefined>();
639
+ const expanded = new Set<string>();
640
+ const pinnedSlots = new Set<string>();
641
+ const registryBase = await this.registryBase();
642
+ const transport = this.options.transport ?? nodeTransport;
643
+
644
+ // `install` reproduces the lockfile, so an unchanged declaration never
645
+ // silently upgrades to a newer registry version. `update` ignores the
646
+ // pins and resolves the declared ranges again.
647
+ const pins = new Map<string, LockedPackage>();
648
+ if (mode === "install") {
649
+ const lock = await readLockfile(this.projectRoot);
650
+ for (const item of lock?.packages ?? []) {
651
+ pins.set(item.slot, item);
652
+ }
653
+ }
654
+
655
+ type Requirement = { slot: string; name: string; range: string; from?: string; registry?: boolean };
656
+ const queue: Requirement[] = [];
657
+ for (const slot of Object.keys(declared).sort((left, right) => left.localeCompare(right, "en"))) {
658
+ const value = declared[slot];
659
+ if (isPathSource(value)) {
660
+ queue.push({ slot, name: value, range: "*" });
661
+ } else {
662
+ const spec = parseRegistrySpec(value);
663
+ // An explicit range asks the registry even when the id also
664
+ // names a built-in preset, so versioned packages stay usable.
665
+ queue.push({ slot, name: spec.name, range: spec.range, registry: spec.range !== "*" });
666
+ }
667
+ }
668
+
669
+ const keyOf = (slot: string, name: string): string => `${slot}\u0000${name}`;
670
+ const expand = (slot: string, name: string, version: RegistryVersion): void => {
671
+ const key = `${keyOf(slot, name)}\u0000${version.version}`;
672
+ if (expanded.has(key)) {
673
+ return;
674
+ }
675
+ expanded.add(key);
676
+ for (const dependency of version.dependencies) {
677
+ queue.push({
678
+ slot: dependency.slot,
679
+ name: dependency.name,
680
+ range: dependency.range ?? "*",
681
+ from: `${slot}:${name}@${version.version}`,
682
+ });
683
+ }
684
+ };
685
+
686
+ /**
687
+ * Reuse the version the lockfile already pinned, as long as it still
688
+ * satisfies the requirement. Reused versions keep their recorded
689
+ * dependencies, so a plain install touches neither metadata nor files.
690
+ */
691
+ const tryPin = (requirement: Requirement): boolean => {
692
+ if (mode !== "install") {
693
+ return false;
694
+ }
695
+ const pin = pins.get(requirement.slot);
696
+ if (pin === undefined || pin.name !== requirement.name) {
697
+ return false;
698
+ }
699
+ if (!pin.source.startsWith("registry+")) {
700
+ return false;
701
+ }
702
+ // A bare id prefers the built-in preset when one exists.
703
+ if (
704
+ requirement.registry !== true &&
705
+ isPackageSlot(pin.slot) &&
706
+ lookupBuiltin(pin.name, pin.slot) !== undefined
707
+ ) {
708
+ return false;
709
+ }
710
+ if (!satisfiesRange(pin.version, requirement.range)) {
711
+ return false;
712
+ }
713
+ const existing = packages.get(requirement.slot);
714
+ if (existing !== undefined) {
715
+ return existing.name === pin.name && existing.version === pin.version;
716
+ }
717
+ packages.set(requirement.slot, {
718
+ slot: pin.slot,
719
+ name: pin.name,
720
+ version: pin.version,
721
+ source: pin.source,
722
+ artifactHash: pin.artifactHash,
723
+ dependencies: [...pin.dependencies],
107
724
  });
725
+ pinnedSlots.add(requirement.slot);
726
+ return true;
727
+ };
728
+
729
+ while (queue.length > 0) {
730
+ const requirement = queue.shift() as Requirement;
731
+ const { slot, name } = requirement;
732
+ const range = requirement.range.length > 0 ? requirement.range : "*";
733
+ if (!isPackageSlot(slot)) {
734
+ diagnostics.push(
735
+ packageDiagnostic("VUTEX_PACKAGE_SLOT_UNKNOWN", `unknown package slot: ${slot}`, { slot }),
736
+ );
737
+ continue;
738
+ }
739
+
740
+ const existing = packages.get(slot);
741
+ if (existing) {
742
+ if (existing.name !== name) {
743
+ diagnostics.push(
744
+ packageDiagnostic(
745
+ "VUTEX_PACKAGE_DEPENDENCY_CONFLICT",
746
+ `${slot} is required as both ${existing.name} and ${name}${requirement.from ? ` by ${requirement.from}` : ""}`,
747
+ { slot, names: [existing.name, name], from: requirement.from },
748
+ ),
749
+ );
750
+ continue;
751
+ }
752
+ if (existing.source === "builtin" || existing.source === "path") {
753
+ if (range !== "*") {
754
+ diagnostics.push(
755
+ packageDiagnostic(
756
+ "VUTEX_PACKAGE_VERSION_NO_MATCH",
757
+ `${existing.name} is a ${existing.source} package and takes no version range`,
758
+ { slot, name, range },
759
+ ),
760
+ );
761
+ }
762
+ continue;
763
+ }
764
+ // A second requirement can rule out the locked version. Drop
765
+ // the pin and resolve the registry with both ranges instead.
766
+ if (pinnedSlots.has(slot) && !satisfiesRange(existing.version, range)) {
767
+ packages.delete(slot);
768
+ pinnedSlots.delete(slot);
769
+ queue.unshift(requirement);
770
+ continue;
771
+ }
772
+ const ranges = rangesBySlot.get(slot) ?? [];
773
+ if (!ranges.includes(range)) {
774
+ ranges.push(range);
775
+ }
776
+ rangesBySlot.set(slot, ranges);
777
+ const found = metadataByKey.get(keyOf(slot, name));
778
+ if (!found) {
779
+ continue;
780
+ }
781
+ const selected = selectVersion(found, ranges);
782
+ if (selected.diagnostic) {
783
+ diagnostics.push(selected.diagnostic);
784
+ continue;
785
+ }
786
+ const version = selected.version as RegistryVersion;
787
+ if (version.version !== existing.version) {
788
+ packages.set(
789
+ slot,
790
+ this.registryPackage(slot, name, version, registryBase, metadataUrls.get(keyOf(slot, name))),
791
+ );
792
+ expand(slot, name, version);
793
+ }
794
+ continue;
795
+ }
796
+
797
+ if (isPathSource(name)) {
798
+ const resolved = await this.resolvePath(slot, name);
799
+ if (resolved.diagnostic) {
800
+ diagnostics.push(resolved.diagnostic);
801
+ } else if (resolved.package) {
802
+ packages.set(slot, resolved.package);
803
+ }
804
+ continue;
805
+ }
806
+
807
+ if (tryPin(requirement)) {
808
+ continue;
809
+ }
810
+
811
+ const builtin = requirement.registry === true ? undefined : lookupBuiltin(name, slot);
812
+ if (builtin) {
813
+ if (range !== "*") {
814
+ diagnostics.push(
815
+ packageDiagnostic(
816
+ "VUTEX_PACKAGE_VERSION_NO_MATCH",
817
+ `${name} is a built-in ${slot} package and takes no version range`,
818
+ { slot, name, range },
819
+ ),
820
+ );
821
+ continue;
822
+ }
823
+ packages.set(slot, {
824
+ slot,
825
+ name: builtin.id,
826
+ version: builtin.version,
827
+ source: "builtin",
828
+ artifactHash: builtin.artifactHash,
829
+ dependencies: [],
830
+ });
831
+ continue;
832
+ }
833
+
834
+ // Registry packages activate from the lockfile and the content
835
+ // addressed cache, so a render never depends on the network.
836
+ if (mode === "activate") {
837
+ continue;
838
+ }
839
+
840
+ if (registryBase === undefined) {
841
+ const scoped = name.startsWith("@");
842
+ diagnostics.push(
843
+ scoped
844
+ ? packageDiagnostic(
845
+ "VUTEX_PACKAGE_REGISTRY_UNCONFIGURED",
846
+ `no registry is configured for ${name}, set [registry] url or VUTEX_REGISTRY`,
847
+ { slot, name },
848
+ )
849
+ : packageDiagnostic(
850
+ slot === "font" || slot === "math-font" ? "VUTEX_PACKAGE_FONT_MISSING" : "VUTEX_PACKAGE_NOT_FOUND",
851
+ `no built-in ${slot} package named ${name}`,
852
+ { slot, name },
853
+ ),
854
+ );
855
+ continue;
856
+ }
857
+
858
+ const key = keyOf(slot, name);
859
+ let found = metadataByKey.get(key);
860
+ if (!found) {
861
+ const fetched = await fetchRegistryMetadata(transport, registryBase, slot, name);
862
+ if (fetched.diagnostic) {
863
+ diagnostics.push(fetched.diagnostic);
864
+ continue;
865
+ }
866
+ found = fetched.metadata as RegistryMetadata;
867
+ metadataByKey.set(key, found);
868
+ metadataUrls.set(key, fetched.metadataUrl);
869
+ }
870
+ const ranges = rangesBySlot.get(slot) ?? [];
871
+ if (!ranges.includes(range)) {
872
+ ranges.push(range);
873
+ }
874
+ rangesBySlot.set(slot, ranges);
875
+ const selected = selectVersion(found, ranges);
876
+ if (selected.diagnostic) {
877
+ diagnostics.push(selected.diagnostic);
878
+ continue;
879
+ }
880
+ const version = selected.version as RegistryVersion;
881
+ packages.set(slot, this.registryPackage(slot, name, version, registryBase, metadataUrls.get(key)));
882
+ expand(slot, name, version);
883
+ }
884
+
885
+ // Dependency references name the versions the plan actually chose. Reused
886
+ // lock entries keep their recorded references untouched.
887
+ for (const item of packages.values()) {
888
+ if (!item.source.startsWith("registry+") || pinnedSlots.has(item.slot)) {
889
+ continue;
890
+ }
891
+ const found = metadataByKey.get(keyOf(item.slot, item.name));
892
+ const version = found?.versions.find((entry) => entry.version === item.version);
893
+ item.dependencies = (version?.dependencies ?? [])
894
+ .map((dependency) => packages.get(dependency.slot))
895
+ .filter((dependency): dependency is ResolvedPackage => dependency !== undefined)
896
+ .map((dependency) => `${dependency.slot}:${dependency.name}@${dependency.version}`)
897
+ .sort((left, right) => left.localeCompare(right, "en"));
898
+ }
899
+
900
+ // A reused lock entry brings its dependency references along, so their
901
+ // targets must enter the plan even when nothing declares them.
902
+ const pending = [...pinnedSlots];
903
+ while (pending.length > 0) {
904
+ const slot = pending.pop() as string;
905
+ const item = packages.get(slot);
906
+ if (item === undefined) {
907
+ continue;
908
+ }
909
+ for (const reference of item.dependencies) {
910
+ const separator = reference.indexOf(":");
911
+ const at = reference.lastIndexOf("@");
912
+ if (separator <= 0 || at <= separator) {
913
+ diagnostics.push(
914
+ packageDiagnostic(
915
+ "VUTEX_PACKAGE_LOCK_INCOMPLETE",
916
+ `${slot} locks an unreadable dependency reference: ${reference}`,
917
+ { slot, reference },
918
+ ),
919
+ );
920
+ continue;
921
+ }
922
+ const refSlot = reference.slice(0, separator);
923
+ const refName = reference.slice(separator + 1, at);
924
+ const refVersion = reference.slice(at + 1);
925
+ const target = packages.get(refSlot);
926
+ if (target !== undefined) {
927
+ if (target.name !== refName || target.version !== refVersion) {
928
+ diagnostics.push(
929
+ packageDiagnostic(
930
+ "VUTEX_PACKAGE_DEPENDENCY_CONFLICT",
931
+ `${refSlot} is locked as ${refName}@${refVersion} but the plan resolved ${target.name}@${target.version}`,
932
+ { slot: refSlot, expected: `${refName}@${refVersion}`, actual: `${target.name}@${target.version}` },
933
+ ),
934
+ );
935
+ }
936
+ continue;
937
+ }
938
+ const pin = pins.get(refSlot);
939
+ if (pin === undefined || pin.name !== refName || pin.version !== refVersion) {
940
+ diagnostics.push(
941
+ packageDiagnostic(
942
+ "VUTEX_PACKAGE_LOCK_INCOMPLETE",
943
+ `${slot} depends on ${reference} which the lockfile does not contain`,
944
+ { slot, reference },
945
+ ),
946
+ );
947
+ continue;
948
+ }
949
+ packages.set(refSlot, {
950
+ slot: pin.slot,
951
+ name: pin.name,
952
+ version: pin.version,
953
+ source: pin.source,
954
+ artifactHash: pin.artifactHash,
955
+ dependencies: [...pin.dependencies],
956
+ });
957
+ if (pin.source.startsWith("registry+")) {
958
+ pinnedSlots.add(refSlot);
959
+ pending.push(refSlot);
960
+ }
961
+ }
962
+ }
963
+
964
+ return { packages: [...packages.values()], diagnostics };
965
+ }
966
+
967
+ private registryPackage(
968
+ slot: string,
969
+ name: string,
970
+ version: RegistryVersion,
971
+ registryBase: string | undefined,
972
+ metadataUrl: string | undefined,
973
+ ): ResolvedPackage {
974
+ return {
975
+ slot,
976
+ name,
977
+ version: version.version,
978
+ source: registryBase === undefined ? "registry" : `registry+${registryBase}`,
979
+ artifactHash: version.artifactHash,
980
+ dependencies: [],
981
+ registryFiles: version.files,
982
+ metadataUrl,
983
+ };
984
+ }
985
+
986
+ private async resolvePath(
987
+ slot: string,
988
+ value: string,
989
+ ): Promise<{ package?: ResolvedPackage; diagnostic?: VuTeXDiagnostic }> {
990
+ const raw = value.startsWith("file:") ? value.slice("file:".length) : value;
991
+ const absolute = resolve(this.projectRoot, raw);
992
+ try {
993
+ const info = await stat(absolute);
994
+ if (!info.isDirectory()) {
995
+ return {
996
+ diagnostic: packageDiagnostic(
997
+ "VUTEX_PACKAGE_PATH_MISSING",
998
+ `${slot} path is not a directory: ${value}`,
999
+ { slot, path: absolute },
1000
+ ),
1001
+ };
1002
+ }
1003
+ const tree = await hashTree(absolute);
1004
+ if (tree.files === 0) {
1005
+ return {
1006
+ diagnostic: packageDiagnostic(
1007
+ "VUTEX_PACKAGE_PATH_EMPTY",
1008
+ `${slot} path contains no files: ${value}`,
1009
+ { slot, path: absolute },
1010
+ ),
1011
+ };
1012
+ }
1013
+ const name = relative(this.projectRoot, absolute).replace(/\\/gu, "/");
1014
+ return {
1015
+ package: {
1016
+ slot,
1017
+ name: name.length > 0 ? name : absolute,
1018
+ version: "local",
1019
+ source: "path",
1020
+ artifactHash: tree.artifactHash,
1021
+ dependencies: [],
1022
+ },
1023
+ };
1024
+ } catch (error) {
1025
+ return {
1026
+ diagnostic: packageDiagnostic(
1027
+ "VUTEX_PACKAGE_PATH_MISSING",
1028
+ `${slot} path cannot be read: ${value}`,
1029
+ { slot, path: absolute, cause: error instanceof Error ? error.message : String(error) },
1030
+ ),
1031
+ };
1032
+ }
1033
+ }
1034
+
1035
+ private checkActivation(
1036
+ declared: Record<string, string>,
1037
+ resolved: ResolvedPackage[],
1038
+ locked: LockedPackage[],
1039
+ ): PackageCheck[] {
1040
+ const lockedBySlot = new Map(locked.map((item) => [item.slot, item]));
1041
+ const resolvedBySlot = new Map(resolved.map((item) => [item.slot, item]));
1042
+ // A locked slot that no declaration names is only stale when nothing
1043
+ // depends on it. Dependency-only slots stay locked without activating:
1044
+ // activation is driven by what the document declares.
1045
+ const dependencySlots = new Set<string>();
1046
+ for (const item of locked) {
1047
+ for (const dependency of item.dependencies) {
1048
+ const separator = dependency.indexOf(":");
1049
+ if (separator > 0) {
1050
+ dependencySlots.add(dependency.slice(0, separator));
1051
+ }
1052
+ }
1053
+ }
1054
+ const missing = Object.keys(declared).filter((slot) => !lockedBySlot.has(slot));
1055
+ const stale = locked
1056
+ .filter((item) => !(item.slot in declared) && !dependencySlots.has(item.slot))
1057
+ .map((item) => item.slot);
1058
+ const drifted = Object.keys(declared)
1059
+ .filter((slot) => lockedBySlot.has(slot))
1060
+ .filter((slot) => {
1061
+ const lockedItem = lockedBySlot.get(slot);
1062
+ const resolvedItem = resolvedBySlot.get(slot);
1063
+ return lockedItem && resolvedItem && lockedItem.version !== resolvedItem.version;
1064
+ });
1065
+
1066
+ const checks: PackageCheck[] = [];
1067
+ if (missing.length === 0 && stale.length === 0) {
1068
+ checks.push(passedCheck("activation", "every declared slot is locked and active"));
108
1069
  } else {
109
- checks.push({
110
- id: "lockfile",
111
- ok: true,
112
- message: "no resource packages declared",
1070
+ const parts: string[] = [];
1071
+ if (missing.length > 0) {
1072
+ parts.push(`unactivated: ${missing.join(", ")}`);
1073
+ }
1074
+ if (stale.length > 0) {
1075
+ parts.push(`stale: ${stale.join(", ")}`);
1076
+ }
1077
+ checks.push(failedCheck("activation", "VUTEX_PACKAGE_UNACTIVATED", parts.join(" but ")));
1078
+ }
1079
+
1080
+ checks.push(
1081
+ drifted.length === 0
1082
+ ? passedCheck("version", "locked versions match resolved packages")
1083
+ : failedCheck(
1084
+ "version",
1085
+ "VUTEX_PACKAGE_VERSION_DRIFT",
1086
+ `manifest and lock disagree for: ${drifted.join(", ")}`,
1087
+ ),
1088
+ );
1089
+ return checks;
1090
+ }
1091
+
1092
+ private checkIntegrity(locked: LockedPackage[]): PackageCheck[] {
1093
+ const broken = locked.filter((item) => contentDigest(item) !== item.contentHash);
1094
+ return broken.length === 0
1095
+ ? [passedCheck("integrity", "lockfile record digests match their records")]
1096
+ : [
1097
+ failedCheck(
1098
+ "integrity",
1099
+ "VUTEX_PACKAGE_HASH_MISMATCH",
1100
+ `record digest mismatch for: ${broken.map((item) => item.slot).join(", ")}`,
1101
+ ),
1102
+ ];
1103
+ }
1104
+
1105
+ private checkArtifacts(resolved: ResolvedPackage[], locked: LockedPackage[]): PackageCheck[] {
1106
+ const resolvedBySlot = new Map(resolved.map((item) => [item.slot, item]));
1107
+ const broken = locked
1108
+ .filter((item) => resolvedBySlot.has(item.slot))
1109
+ .filter((item) => {
1110
+ const source = resolvedBySlot.get(item.slot) as ResolvedPackage;
1111
+ // A registry metadata document may omit the tree hash. The lock
1112
+ // keeps the hash `install` computed, so there is nothing to compare.
1113
+ if (source.source.startsWith("registry+") && source.artifactHash === undefined) {
1114
+ return false;
1115
+ }
1116
+ return source.artifactHash !== item.artifactHash;
113
1117
  });
1118
+ return broken.length === 0
1119
+ ? [passedCheck("artifacts", "resource artifact hashes match their sources")]
1120
+ : [
1121
+ failedCheck(
1122
+ "artifacts",
1123
+ "VUTEX_PACKAGE_ARTIFACT_MISMATCH",
1124
+ `artifact hash mismatch for: ${broken.map((item) => item.slot).join(", ")}`,
1125
+ ),
1126
+ ];
1127
+ }
1128
+
1129
+ private async checkCache(locked: LockedPackage[]): Promise<PackageCheck[]> {
1130
+ const registry = locked.filter((item) => item.source.startsWith("registry+") && item.artifactHash !== undefined);
1131
+ if (registry.length === 0) {
1132
+ return [];
1133
+ }
1134
+ const broken: string[] = [];
1135
+ for (const item of registry) {
1136
+ const cache = this.registryCachePath(item.slot, item.name, item.artifactHash as string);
1137
+ try {
1138
+ const tree = await hashTree(cache);
1139
+ if (tree.artifactHash !== item.artifactHash) {
1140
+ broken.push(item.slot);
1141
+ }
1142
+ } catch {
1143
+ broken.push(item.slot);
1144
+ }
114
1145
  }
1146
+ return broken.length === 0
1147
+ ? [passedCheck("cache", `${registry.length} registry package(s) cached and intact`)]
1148
+ : [
1149
+ failedCheck(
1150
+ "cache",
1151
+ "VUTEX_PACKAGE_ARTIFACT_MISSING",
1152
+ `not cached or corrupt: ${broken.join(", ")}`,
1153
+ ),
1154
+ ];
1155
+ }
1156
+
1157
+ private checkProfile(
1158
+ profileName: string | undefined,
1159
+ declared: Record<string, string>,
1160
+ ): PackageCheck[] {
1161
+ const packageProfile = declared.profile;
1162
+ if (packageProfile === undefined || profileName === undefined) {
1163
+ return [passedCheck("profile", "no profile conflict")];
1164
+ }
1165
+ if (packageProfile === profileName) {
1166
+ return [passedCheck("profile", `profile matches the active profile ${profileName}`)];
1167
+ }
1168
+ return [
1169
+ failedCheck(
1170
+ "profile",
1171
+ "VUTEX_PACKAGE_PROFILE_CONFLICT",
1172
+ `profile package ${packageProfile} conflicts with profile ${profileName}`,
1173
+ ),
1174
+ ];
1175
+ }
115
1176
 
116
- const ok = checks.every((check) => check.ok);
117
- return { ok, checks };
1177
+ private async writeManifest(manifest: VuTeXManifest): Promise<void> {
1178
+ await writeFile(join(this.projectRoot, "vutex.toml"), `${TOML.stringify(manifest)}\n`, "utf8");
118
1179
  }
119
1180
  }