@vutex/vutex 0.0.13 → 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.
- package/licenses/NotoSansMono.OFL.md +93 -0
- package/licenses/NotoSerifSC.OFL.md +94 -0
- package/licenses/Tinos.OFL.md +93 -0
- package/locales/en-US/cli.json +5 -4
- package/locales/locales.json +6 -1
- package/package.json +8 -7
- package/readme.md +29 -3
- package/src/cli/commands/package.ts +41 -12
- package/src/cli/commands/render.ts +5 -1
- package/src/index.ts +3 -0
- package/src/node/load.ts +18 -22
- package/src/packages/builtin.ts +129 -0
- package/src/packages/lockfile.ts +33 -1
- package/src/packages/manager.ts +1125 -64
- package/src/packages/manifest.ts +45 -0
- package/src/packages/math_font.ts +63 -0
- package/src/packages/registry.ts +405 -0
- package/src/packages/template.ts +352 -0
- package/src/runtime.ts +387 -37
- package/src/types.ts +167 -15
package/src/packages/manager.ts
CHANGED
|
@@ -1,18 +1,143 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
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(
|
|
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)
|
|
25
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
423
|
+
await this.resolveAndInstall({}, "update");
|
|
89
424
|
}
|
|
90
425
|
|
|
91
426
|
async doctor(): Promise<PackageDoctorReport> {
|
|
92
|
-
const 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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
}
|