@moewolf/moe-icons-cli 0.0.11 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -19
- package/dist/cli.js +124 -146
- package/dist/config-package/generated/SOURCE.json +3 -3
- package/dist/config-package/generated/moeicons-config.schema.json +1 -1
- package/dist/core/bootstrap.js +5 -3
- package/dist/core/candidate-version-pointer.d.ts +13 -0
- package/dist/core/candidate-version-pointer.js +37 -0
- package/dist/core/config-package-plan.d.ts +25 -0
- package/dist/core/config-package-plan.js +156 -0
- package/dist/core/doctor.js +4 -0
- package/dist/core/free-download.js +34 -4
- package/dist/core/free-small-package-download.d.ts +67 -0
- package/dist/core/free-small-package-download.js +344 -0
- package/dist/core/generate.js +49 -33
- package/dist/core/install.d.ts +8 -2
- package/dist/core/install.js +70 -0
- package/dist/core/library-update.d.ts +6 -1
- package/dist/core/library-update.js +54 -0
- package/dist/core/manage.d.ts +4 -0
- package/dist/core/manage.js +4 -0
- package/dist/core/metadata-sync.js +119 -1
- package/dist/core/pro-download.js +19 -2
- package/dist/core/pro-install.d.ts +10 -4
- package/dist/core/pro-install.js +47 -0
- package/dist/core/pro-resources.d.ts +23 -0
- package/dist/core/pro-resources.js +51 -0
- package/dist/core/pro-selected-download.js +12 -2
- package/dist/core/pro-small-package-download.d.ts +61 -0
- package/dist/core/pro-small-package-download.js +301 -0
- package/dist/core/release-descriptor.js +6 -2
- package/dist/core/small-pack/PIN.json +11 -0
- package/dist/core/small-pack/candidate-set-v1.schema.json +187 -0
- package/dist/core/small-pack/candidate-set.cjs +286 -0
- package/dist/core/small-pack/candidate-set.d.cts +12 -0
- package/dist/core/small-pack/tar-read.cjs +220 -0
- package/dist/core/small-pack/tar-read.d.cts +2 -0
- package/dist/core/small-package-install.d.ts +40 -0
- package/dist/core/small-package-install.js +252 -0
- package/dist/core/small-package-selection.d.ts +27 -0
- package/dist/core/small-package-selection.js +114 -0
- package/dist/core/update-policy.d.ts +1 -0
- package/dist/core/update-policy.js +10 -3
- package/dist/mcp/services.js +1 -1
- package/dist/project/config.js +1 -1
- package/dist/project/install-metadata.d.ts +2 -1
- package/dist/project/install-metadata.js +6 -3
- package/dist/project/install.d.ts +11 -2
- package/dist/project/install.js +104 -12
- package/dist/project/package-install-metadata.d.ts +31 -0
- package/dist/project/package-install-metadata.js +99 -0
- package/dist/project/tar-gz.d.ts +4 -8
- package/dist/project/tar-gz.js +6 -10
- package/package.json +11 -11
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { findCatalogStyleGroup, parseCatalog } from '../catalog/catalog.js';
|
|
2
|
+
import { resolveIconTheme } from './icon-selection.js';
|
|
3
|
+
import { resolveResourceVariant } from './resource-variant.js';
|
|
4
|
+
import { planSelectedPackages } from './small-package-selection.js';
|
|
5
|
+
function check(ok, message) { if (!ok)
|
|
6
|
+
throw new Error(`config package plan: ${message}`); }
|
|
7
|
+
export function planConfigPackages(config, catalog, candidate) {
|
|
8
|
+
check(config.icons.length > 0 && Object.keys(config.themes).length > 0, 'registered icons and themes required');
|
|
9
|
+
check(config.downloadMode !== 'full', 'published package installs cannot use downloadMode=full');
|
|
10
|
+
check(config.tier === candidate.root.tier, 'config tier differs from pinned candidate');
|
|
11
|
+
check(catalog.sourceVersion === candidate.root.fullVersion && catalog.sourceCommit === candidate.root.sourceCommit && catalog.generatorCommit === candidate.root.generatorCommit, 'catalog differs from pinned candidate identity');
|
|
12
|
+
const keys = new Set(), fallbacks = new Set();
|
|
13
|
+
for (const iconId of config.icons)
|
|
14
|
+
for (const requested of Object.keys(config.themes)) {
|
|
15
|
+
const actual = resolveIconTheme(config, catalog, requested, iconId);
|
|
16
|
+
check(actual, `no selected variant for ${requested}/${iconId}`);
|
|
17
|
+
if (actual !== requested)
|
|
18
|
+
fallbacks.add(`${iconId}: ${requested} -> ${actual}`);
|
|
19
|
+
const theme = config.themes[actual];
|
|
20
|
+
check(theme, 'resolved theme missing');
|
|
21
|
+
const group = findCatalogStyleGroup(theme.styleGroup, catalog);
|
|
22
|
+
check(group && group.tiers.includes(config.tier), 'group is unavailable in selected tier');
|
|
23
|
+
keys.add(`metadata/${config.tier}/${group.id}`);
|
|
24
|
+
if (group.type === 'bitmap') {
|
|
25
|
+
check(config.target !== 'vanilla', 'Vanilla does not support bitmap icons');
|
|
26
|
+
const variant = resolveResourceVariant(group.id, { ...(theme.format ? { format: theme.format } : {}), ...(theme.imageSize ? { imageSize: theme.imageSize } : {}) });
|
|
27
|
+
check(group.formats.includes(variant.format) && group.imageSizes.includes(variant.imageSize) && group.variants?.includes(variant.resourceVariantId), 'bitmap tuple is not declared by pinned catalog');
|
|
28
|
+
keys.add(`bitmap/${config.tier}/${group.id}/${variant.imageSize}x${variant.imageSize}/${variant.format}`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (config.target === 'assets' || config.target === 'vanilla')
|
|
32
|
+
keys.add(`svg/${config.tier}/${group.id}`);
|
|
33
|
+
if (config.target !== 'assets')
|
|
34
|
+
keys.add(`code/${config.tier}/${config.target}/${group.id}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const resourceKeys = [...keys].sort();
|
|
38
|
+
return { resourceKeys, packages: planSelectedPackages(candidate, resourceKeys), fallbacks: [...fallbacks].sort() };
|
|
39
|
+
}
|
|
40
|
+
function stable(value) {
|
|
41
|
+
if (Array.isArray(value))
|
|
42
|
+
return value.map(stable);
|
|
43
|
+
if (value && typeof value === 'object')
|
|
44
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b, 'en')).map(([key, item]) => [key, stable(item)]));
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function stringArray(value) {
|
|
48
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
49
|
+
}
|
|
50
|
+
/** Bootstrap only the scoped metadata needed by this project's themes, then
|
|
51
|
+
* plan exact content units against the resulting candidate-bound catalog. The
|
|
52
|
+
* loader must return bytes from the selected-package verifier. */
|
|
53
|
+
export async function planConfigPackagesFromMetadata(config, candidate, loadVerifiedPackage) {
|
|
54
|
+
check(config.icons.length > 0 && Object.keys(config.themes).length > 0, 'registered icons and themes required');
|
|
55
|
+
check(config.downloadMode !== 'full', 'published package installs cannot use downloadMode=full');
|
|
56
|
+
check(config.tier === candidate.root.tier, 'config tier differs from pinned candidate');
|
|
57
|
+
const groupIds = [...new Set(Object.values(config.themes)
|
|
58
|
+
.filter(theme => theme.icons === undefined || theme.icons.some(icon => config.icons.includes(icon)))
|
|
59
|
+
.map(theme => theme.styleGroup))].sort();
|
|
60
|
+
check(groupIds.length > 0, 'configured themes required');
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
const icons = new Map();
|
|
63
|
+
for (const groupId of groupIds) {
|
|
64
|
+
const row = candidate.rows.find(item => item.resourceKey === `metadata/${config.tier}/${groupId}`);
|
|
65
|
+
check(row && row.kind === 'metadata' && row.styleGroupId === groupId && row.tier === config.tier, `scoped metadata missing for ${groupId}`);
|
|
66
|
+
const files = await loadVerifiedPackage(row);
|
|
67
|
+
const bytes = files['metadata/catalog.json'];
|
|
68
|
+
check(bytes, `scoped catalog missing for ${groupId}`);
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error(`config package plan: invalid scoped catalog for ${groupId}`);
|
|
75
|
+
}
|
|
76
|
+
check(raw !== null && typeof raw === 'object' && !Array.isArray(raw), `scoped catalog object required for ${groupId}`);
|
|
77
|
+
const scoped = raw;
|
|
78
|
+
check(Object.keys(scoped).sort().join(',') === 'icons,schemaVersion,styleGroups' && scoped.schemaVersion === 1 && Array.isArray(scoped.styleGroups) && scoped.styleGroups.length === 1 && Array.isArray(scoped.icons), `scoped catalog schema/scope invalid for ${groupId}`);
|
|
79
|
+
const group = scoped.styleGroups[0];
|
|
80
|
+
check(group && group.id === groupId && !groups.has(groupId), `scoped style group differs for ${groupId}`);
|
|
81
|
+
groups.set(groupId, group);
|
|
82
|
+
check(scoped.icons.length > 0, `scoped catalog has no icons for ${groupId}`);
|
|
83
|
+
for (const value of scoped.icons) {
|
|
84
|
+
check(value !== null && typeof value === 'object' && !Array.isArray(value), `invalid scoped icon in ${groupId}`);
|
|
85
|
+
const icon = value;
|
|
86
|
+
check(typeof icon.id === 'string' && Array.isArray(icon.availableIn) && icon.availableIn.length === 1 && icon.availableIn[0] === groupId, `scoped icon crosses group boundary in ${groupId}`);
|
|
87
|
+
if (icon.variants !== undefined)
|
|
88
|
+
check(stringArray(icon.variants), `invalid scoped icon variants in ${groupId}`);
|
|
89
|
+
const previous = icons.get(icon.id);
|
|
90
|
+
if (!previous) {
|
|
91
|
+
icons.set(icon.id, { ...icon, availableIn: [groupId], ...(stringArray(icon.variants) ? { variants: [...icon.variants] } : {}) });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const stripScope = (entry) => Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'availableIn' && key !== 'variants'));
|
|
95
|
+
check(JSON.stringify(stable(stripScope(previous))) === JSON.stringify(stable(stripScope(icon))), `icon metadata conflicts across scoped catalogs: ${icon.id}`);
|
|
96
|
+
previous.availableIn = [...new Set([...previous.availableIn, groupId])].sort();
|
|
97
|
+
if (stringArray(icon.variants))
|
|
98
|
+
previous.variants = [...new Set([...(stringArray(previous.variants) ? previous.variants : []), ...icon.variants])].sort();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const catalog = parseCatalog({
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
catalogVersion: candidate.root.fullVersion,
|
|
104
|
+
sourceVersion: candidate.root.fullVersion,
|
|
105
|
+
sourceCommit: candidate.root.sourceCommit,
|
|
106
|
+
generatorCommit: candidate.root.generatorCommit,
|
|
107
|
+
styleGroups: [...groups.values()],
|
|
108
|
+
icons: [...icons.values()],
|
|
109
|
+
});
|
|
110
|
+
return { catalog, ...planConfigPackages(config, catalog, candidate) };
|
|
111
|
+
}
|
|
112
|
+
/** Map one already verified unit; never accumulate multiple packages or invent
|
|
113
|
+
* an all-tier archive identity. Callers stage these files transactionally. */
|
|
114
|
+
export function packageTargetFiles(row, files, target) {
|
|
115
|
+
check(Object.hasOwn(files, 'manifest.json'), 'verified unit manifest required');
|
|
116
|
+
const output = Object.create(null);
|
|
117
|
+
let area;
|
|
118
|
+
if (row.kind === 'code' || row.kind === 'shared') {
|
|
119
|
+
check(row.target === target, `package target mismatch for ${row.resourceKey}: expected ${target}, received ${row.target}`);
|
|
120
|
+
area = 'target';
|
|
121
|
+
}
|
|
122
|
+
else if (row.kind === 'svg' || row.kind === 'bitmap') {
|
|
123
|
+
check(row.kind !== 'svg' || target === 'assets' || target === 'vanilla', 'raw SVG unit is not required by compiled target');
|
|
124
|
+
check(target !== 'vanilla' || row.kind !== 'bitmap', 'Vanilla bitmap unsupported');
|
|
125
|
+
area = 'assets';
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
check(row.kind === 'metadata', 'unsupported installation package kind');
|
|
129
|
+
area = 'metadata';
|
|
130
|
+
}
|
|
131
|
+
for (const [name, bytes] of Object.entries(files)) {
|
|
132
|
+
if (name === 'manifest.json')
|
|
133
|
+
continue;
|
|
134
|
+
check(!/[\\:]/.test(name) && name.split('/').every(part => part && part !== '.' && part !== '..'), 'unsafe unit path');
|
|
135
|
+
let destination;
|
|
136
|
+
if (row.kind === 'shared')
|
|
137
|
+
destination = name;
|
|
138
|
+
else if (row.kind === 'code' || row.kind === 'svg') {
|
|
139
|
+
check(row.styleGroupId, 'group scope missing');
|
|
140
|
+
destination = `${row.styleGroupId}/${name}`;
|
|
141
|
+
}
|
|
142
|
+
else if (row.kind === 'metadata') {
|
|
143
|
+
check(row.styleGroupId && name.startsWith('metadata/'), 'metadata scope missing');
|
|
144
|
+
destination = `${row.styleGroupId}/${name.slice(9)}`;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
check(row.styleGroupId && row.variant && name.startsWith('icons/'), 'bitmap tuple scope missing');
|
|
148
|
+
const variant = resolveResourceVariant(row.styleGroupId, { format: row.variant.format, imageSize: row.variant.width });
|
|
149
|
+
destination = `${variant.resourceVariantId}/${name.slice(6)}`;
|
|
150
|
+
}
|
|
151
|
+
check(!Object.hasOwn(output, destination), 'duplicate target file');
|
|
152
|
+
output[destination] = bytes;
|
|
153
|
+
}
|
|
154
|
+
check(Object.keys(output).length > 0, 'empty installation unit');
|
|
155
|
+
return { area, files: output };
|
|
156
|
+
}
|
package/dist/core/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { diagnoseProject } from "../project/anchors/diagnose.js";
|
|
|
4
4
|
import { applyPlannedChanges } from "../project/anchors/apply.js";
|
|
5
5
|
import { withProjectLockSync } from "../project/project-lock.js";
|
|
6
6
|
import { loadInstalledCatalogState } from "./generate.js";
|
|
7
|
+
import { readInstalledResourceState } from "../project/install-metadata.js";
|
|
7
8
|
const STATUS_LABEL = {
|
|
8
9
|
ok: "OK",
|
|
9
10
|
missing: "MISSING",
|
|
@@ -92,6 +93,9 @@ export function checkRequiresFix(report) {
|
|
|
92
93
|
return report.anchors.some((a) => required.includes(a.kind) && a.status !== "ok" && a.status !== "not-required");
|
|
93
94
|
}
|
|
94
95
|
function diagnosticCatalog(cwd) {
|
|
96
|
+
const installed = readInstalledResourceState(cwd);
|
|
97
|
+
if (installed.kind === "invalid")
|
|
98
|
+
throw new Error(installed.message);
|
|
95
99
|
const state = loadInstalledCatalogState(cwd, { readFileSync, existsSync });
|
|
96
100
|
if (state.status === "invalid")
|
|
97
101
|
throw new Error(state.message);
|
|
@@ -12,7 +12,9 @@ import { cacheArtifact } from "./cache.js";
|
|
|
12
12
|
import { extractAndVerifyMetadataArchive } from "../metadata/archive.js";
|
|
13
13
|
import { assertLocalCandidateAllowed, cacheKey, FREE_DOWNLOAD_HOSTS, githubReleaseAssetUrl, parseReleaseDescriptor, parseSha256Sidecar, } from "./release-descriptor.js";
|
|
14
14
|
export const DESCRIPTOR_MAX_BYTES = 256 * 1024;
|
|
15
|
-
|
|
15
|
+
// Full release archives are retired for published versions. This bound only
|
|
16
|
+
// preserves small local-test fixtures until package-scoped target delivery lands.
|
|
17
|
+
export const ARTIFACT_MAX_BYTES = 128 * 1024 * 1024;
|
|
16
18
|
export const METADATA_MAX_BYTES = 8 * 1024 * 1024;
|
|
17
19
|
// GitHub Release downloads can be slow on constrained or distant networks.
|
|
18
20
|
// Keep the operation bounded, but allow enough time for the 64 MiB Free limit.
|
|
@@ -342,6 +344,27 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
|
|
|
342
344
|
if (!fetched.ok)
|
|
343
345
|
return fetched;
|
|
344
346
|
const { descriptor, descriptorSha256, tag } = fetched;
|
|
347
|
+
// CI FLOW RULE (S05): the old Free descriptor points at a tier-wide archive.
|
|
348
|
+
// Never silently download that complete library when the group package
|
|
349
|
+
// directory or selected package support is missing.
|
|
350
|
+
const localFixture = Boolean(io.fixtureDir || io.fixtureBaseUrl || descriptor.channel === "local-test");
|
|
351
|
+
if (!localFixture && (descriptor.schemaVersion === undefined || descriptor.schemaVersion < 5)) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: "validation",
|
|
355
|
+
message: "this release uses the retired full-library package format; install requires the small-package release protocol",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// CI FLOW RULE (S05): a current-looking descriptor must not make the CLI
|
|
359
|
+
// fetch the retired tier-wide index/`.bin`. Published installs require the
|
|
360
|
+
// candidate-set package directory and its bounded dependency closure.
|
|
361
|
+
if (!localFixture && descriptor.free.resources !== undefined) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
reason: "validation",
|
|
365
|
+
message: "this release advertises the retired tier-wide resource bundle; refusing to download it",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
345
368
|
const metadataRef = descriptor.free.metadata;
|
|
346
369
|
if (!metadataRef) {
|
|
347
370
|
return {
|
|
@@ -479,15 +502,22 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
|
|
|
479
502
|
};
|
|
480
503
|
}
|
|
481
504
|
}
|
|
505
|
+
if (!localFixture) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
reason: "validation",
|
|
509
|
+
message: "published Free release has no supported bounded package directory; refusing the full-library fallback",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
482
512
|
if (mode === "icons")
|
|
483
513
|
return {
|
|
484
514
|
ok: false,
|
|
485
515
|
reason: "validation",
|
|
486
|
-
message: "this
|
|
516
|
+
message: "this local fixture has no selected-resource index",
|
|
487
517
|
};
|
|
488
518
|
selection?.onPlan?.(mode === "full"
|
|
489
|
-
? "Download mode: full (
|
|
490
|
-
: "
|
|
519
|
+
? "Download mode: full (local fixture only)."
|
|
520
|
+
: "Using a local-test fixture archive.");
|
|
491
521
|
const artifactCache = cachePath(io, descriptor.fullVersion, descriptor.free.sha256);
|
|
492
522
|
if (io.existsSync(artifactCache)) {
|
|
493
523
|
const cached = io.readFileSync(artifactCache);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type PackageRow } from './small-pack/candidate-set.cjs';
|
|
2
|
+
import { type CandidateIdentity, type SelectedCandidate } from './small-package-selection.js';
|
|
3
|
+
export interface FreeSmallPackageOptions {
|
|
4
|
+
expected: CandidateIdentity;
|
|
5
|
+
resourceKeys?: readonly string[];
|
|
6
|
+
resolveResourceKeys?: (candidate: SelectedCandidate, loadVerifiedPackage: (row: PackageRow) => Promise<Readonly<Record<string, Uint8Array>>>) => Promise<readonly string[]>;
|
|
7
|
+
cacheDir: string;
|
|
8
|
+
consume: (row: PackageRow, files: Readonly<Record<string, Uint8Array>>) => Promise<void>;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
fetchFn?: typeof fetch;
|
|
11
|
+
/** Explicit loopback fixture origin, never a published remote override. */
|
|
12
|
+
fixtureOrigin?: string;
|
|
13
|
+
/** Bound by a public immutable version pointer when resolving by version. */
|
|
14
|
+
expectedRootSha256?: string;
|
|
15
|
+
expectedRootBytes?: number;
|
|
16
|
+
/** Public Worker API base; production defaults to https://api.moeicons.com. */
|
|
17
|
+
apiBaseUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function downloadFreeSelectedPackages(options: FreeSmallPackageOptions): Promise<{
|
|
20
|
+
packageCount: number;
|
|
21
|
+
networkBytes: number;
|
|
22
|
+
cacheHits: number;
|
|
23
|
+
metadataNetworkBytes: number;
|
|
24
|
+
metadataCacheHits: number;
|
|
25
|
+
transferRetryCount: number;
|
|
26
|
+
}>;
|
|
27
|
+
export interface FreeSmallPackageVersionOptions extends Omit<FreeSmallPackageOptions, 'expected' | 'expectedRootSha256' | 'expectedRootBytes'> {
|
|
28
|
+
fullVersion: string;
|
|
29
|
+
expectedPackageSetSha256?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Resolve a version through its immutable public pointer, then use the same
|
|
32
|
+
* exact-pin package reader. The pointer is cached only after the candidate
|
|
33
|
+
* root and all requested units have passed verification. */
|
|
34
|
+
export declare function downloadFreeVersionPackages(options: FreeSmallPackageVersionOptions): Promise<{
|
|
35
|
+
packageCount: number;
|
|
36
|
+
networkBytes: number;
|
|
37
|
+
cacheHits: number;
|
|
38
|
+
metadataNetworkBytes: number;
|
|
39
|
+
metadataCacheHits: number;
|
|
40
|
+
transferRetryCount: number;
|
|
41
|
+
}>;
|
|
42
|
+
export interface FreePackageSetIdentity {
|
|
43
|
+
readonly schemaVersion: 1;
|
|
44
|
+
readonly tier: 'free';
|
|
45
|
+
readonly fullVersion: string;
|
|
46
|
+
readonly sourceCommit: string;
|
|
47
|
+
readonly generatorCommit: string;
|
|
48
|
+
readonly packageSetSha256: string;
|
|
49
|
+
readonly candidateRootSha256: string;
|
|
50
|
+
readonly candidateRootBytes: number;
|
|
51
|
+
readonly updatedAt: string;
|
|
52
|
+
readonly networkBytes: number;
|
|
53
|
+
}
|
|
54
|
+
export declare function fetchFreeStablePackageSet(options?: {
|
|
55
|
+
apiBaseUrl?: string;
|
|
56
|
+
fixtureOrigin?: string;
|
|
57
|
+
fetchFn?: typeof fetch;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
}): Promise<FreePackageSetIdentity>;
|
|
60
|
+
export declare function downloadFreeStablePackages(options: Omit<FreeSmallPackageOptions, 'expected' | 'expectedRootSha256' | 'expectedRootBytes'>): Promise<{
|
|
61
|
+
packageCount: number;
|
|
62
|
+
networkBytes: number;
|
|
63
|
+
cacheHits: number;
|
|
64
|
+
metadataNetworkBytes: number;
|
|
65
|
+
metadataCacheHits: number;
|
|
66
|
+
transferRetryCount: number;
|
|
67
|
+
}>;
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { cacheArtifact } from './cache.js';
|
|
4
|
+
import { downloadArtifact } from '../project/download.js';
|
|
5
|
+
import { FREE_DOWNLOAD_HOSTS, githubReleaseAssetUrl } from './release-descriptor.js';
|
|
6
|
+
import { parseCandidateSet, sha256 } from './small-pack/candidate-set.cjs';
|
|
7
|
+
import { readSelectedCandidate, consumeSelectedPackages, planSelectedPackages, verifySelectedPackage } from './small-package-selection.js';
|
|
8
|
+
import { parseFreeCandidateVersionPointer } from './candidate-version-pointer.js';
|
|
9
|
+
function check(ok, message) { if (!ok)
|
|
10
|
+
throw new Error(`Free package download: ${message}`); }
|
|
11
|
+
function readCache(file, maximum, exact) {
|
|
12
|
+
let handle;
|
|
13
|
+
try {
|
|
14
|
+
const entry = fs.lstatSync(file);
|
|
15
|
+
check(entry.isFile() && !entry.isSymbolicLink(), 'cached object must be a regular non-link file');
|
|
16
|
+
handle = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const stat = fs.fstatSync(handle);
|
|
25
|
+
check(stat.isFile() && stat.size > 0 && stat.size <= maximum && (exact === undefined || stat.size === exact), 'cached object size/type invalid');
|
|
26
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
27
|
+
let count = 0;
|
|
28
|
+
while (count < bytes.length) {
|
|
29
|
+
const read = fs.readSync(handle, bytes, count, bytes.length - count, count);
|
|
30
|
+
if (!read)
|
|
31
|
+
break;
|
|
32
|
+
count += read;
|
|
33
|
+
}
|
|
34
|
+
check(count === stat.size, 'cache changed during bounded read');
|
|
35
|
+
return bytes.subarray(0, count);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
fs.closeSync(handle);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function downloadFreeSelectedPackages(options) {
|
|
42
|
+
options.signal?.throwIfAborted();
|
|
43
|
+
check((options.resourceKeys !== undefined) !== (options.resolveResourceKeys !== undefined), 'provide exactly one explicit selection or candidate-based planner');
|
|
44
|
+
if (options.resourceKeys)
|
|
45
|
+
check(options.resourceKeys.length > 0 && new Set(options.resourceKeys).size === options.resourceKeys.length, 'explicit unique resource selection required');
|
|
46
|
+
const expected = options.expected;
|
|
47
|
+
check(expected.tier === 'free' && /^[a-f0-9]{64}$/.test(expected.packageSetSha256) && /^[a-f0-9]{40}$/.test(expected.sourceCommit) && /^[a-f0-9]{40}$/.test(expected.generatorCommit) && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:alpha|beta)(?:\.(?:0|[1-9]\d*))?)?$/.test(expected.fullVersion), 'exact Free candidate pin required');
|
|
48
|
+
if (options.expectedRootSha256 !== undefined)
|
|
49
|
+
check(/^[a-f0-9]{64}$/.test(options.expectedRootSha256), 'candidate root pin must be lowercase SHA-256');
|
|
50
|
+
if (options.expectedRootBytes !== undefined)
|
|
51
|
+
check(Number.isSafeInteger(options.expectedRootBytes) && options.expectedRootBytes > 0 && options.expectedRootBytes <= 1024 ** 2, 'candidate root byte pin is invalid');
|
|
52
|
+
let fixture;
|
|
53
|
+
if (options.fixtureOrigin) {
|
|
54
|
+
fixture = new URL(options.fixtureOrigin);
|
|
55
|
+
check(fixture.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(fixture.hostname) && !fixture.username && !fixture.password && !fixture.search && !fixture.hash && fixture.pathname === '/', 'fixture must be an explicit loopback origin');
|
|
56
|
+
}
|
|
57
|
+
const apiBase = resolveFreeApiBase(options.apiBaseUrl, fixture);
|
|
58
|
+
const url = (tag, filename) => fixture ? new URL(`/moewolf-dev/moe-icons/releases/download/${tag}/${filename}`, fixture).href : githubReleaseAssetUrl(tag, filename);
|
|
59
|
+
let transferRetryCount = 0;
|
|
60
|
+
const fetchBytes = async (address, maximum) => {
|
|
61
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
62
|
+
options.signal?.throwIfAborted();
|
|
63
|
+
const result = await downloadArtifact(address, { maxBytes: maximum, timeoutMs: 120000, maxRedirects: 3, allowedHosts: fixture ? [fixture.host] : apiBase.protocol === 'http:' ? [apiBase.host] : [...FREE_DOWNLOAD_HOSTS, apiBase.host], ...(fixture || apiBase.protocol === 'http:' ? { allowHttpLoopback: true } : {}), userAgent: 'moeicons/small-package' }, { ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}), ...(options.signal ? { signal: options.signal } : {}) });
|
|
64
|
+
if (result.ok)
|
|
65
|
+
return result.bytes;
|
|
66
|
+
const transient = result.code === 'NETWORK_ERROR' || result.code === 'HTTP_ERROR' && /\b(?:429|500|502|503|504)\b/.test(result.message);
|
|
67
|
+
if (!transient || attempt === 2)
|
|
68
|
+
throw new Error(`Free package download: ${result.code}: ${result.message}`);
|
|
69
|
+
transferRetryCount++;
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
const aborted = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', aborted); reject(new Error('Free package download cancelled')); };
|
|
72
|
+
const timer = setTimeout(() => { options.signal?.removeEventListener('abort', aborted); resolve(); }, 250 * (attempt + 1));
|
|
73
|
+
options.signal?.addEventListener('abort', aborted, { once: true });
|
|
74
|
+
if (options.signal?.aborted)
|
|
75
|
+
aborted();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Free package download retry budget exhausted');
|
|
79
|
+
};
|
|
80
|
+
const cache = join(options.cacheDir, 'small-pack-v1', 'free');
|
|
81
|
+
const io = { mkdirSync: (dir) => { fs.mkdirSync(dir, { recursive: true }); }, writeFileSync: fs.writeFileSync, renameSync: fs.renameSync, existsSync: fs.existsSync, rmSync: fs.rmSync, readFileSync: fs.readFileSync, readdirSync: fs.readdirSync };
|
|
82
|
+
const persist = (name, bytes, digest) => cacheArtifact(io, join(cache, name), bytes, digest);
|
|
83
|
+
const tag = `candidate-set-v1-free-${expected.packageSetSha256}`;
|
|
84
|
+
const rootName = `candidate-set-${expected.packageSetSha256}.json`;
|
|
85
|
+
let metadataNetworkBytes = 0, metadataCacheHits = 0;
|
|
86
|
+
let rootBytes = readCache(join(cache, rootName), 1024 ** 2);
|
|
87
|
+
const rootCached = rootBytes !== undefined;
|
|
88
|
+
if (rootBytes)
|
|
89
|
+
metadataCacheHits++;
|
|
90
|
+
else {
|
|
91
|
+
const address = fixture ? url(tag, 'candidate-set-free.json') : freePackageSetAssetUrl(apiBase, expected, 'root');
|
|
92
|
+
rootBytes = await fetchBytes(address, 1024 ** 2);
|
|
93
|
+
metadataNetworkBytes += rootBytes.length;
|
|
94
|
+
}
|
|
95
|
+
if (options.expectedRootSha256)
|
|
96
|
+
check(sha256(rootBytes) === options.expectedRootSha256, 'candidate root differs from immutable version pointer');
|
|
97
|
+
if (options.expectedRootBytes !== undefined)
|
|
98
|
+
check(rootBytes.length === options.expectedRootBytes, 'candidate root size differs from immutable identity');
|
|
99
|
+
const root = parseCandidateSet(rootBytes);
|
|
100
|
+
// Reject identity drift before any page request or cache write.
|
|
101
|
+
for (const key of ['fullVersion', 'tier', 'sourceCommit', 'generatorCommit', 'packageSetSha256'])
|
|
102
|
+
check(root[key] === expected[key], 'root differs from pinned Free identity');
|
|
103
|
+
const pending = [];
|
|
104
|
+
const candidate = await readSelectedCandidate(rootBytes, expected, async (ref) => {
|
|
105
|
+
const name = `page-${ref.sha256}.json`;
|
|
106
|
+
let bytes = readCache(join(cache, name), ref.sizeBytes, ref.sizeBytes);
|
|
107
|
+
if (bytes)
|
|
108
|
+
metadataCacheHits++;
|
|
109
|
+
else {
|
|
110
|
+
const index = root.packagePages.findIndex((item) => item.filename === ref.filename && item.sha256 === ref.sha256) + 1;
|
|
111
|
+
check(index > 0, 'page reference is not in the verified root');
|
|
112
|
+
const address = fixture ? url(tag, ref.filename) : freePackageSetAssetUrl(apiBase, expected, `pages/${index}`);
|
|
113
|
+
bytes = await fetchBytes(address, ref.sizeBytes);
|
|
114
|
+
metadataNetworkBytes += bytes.length;
|
|
115
|
+
pending.push({ name, bytes, digest: ref.sha256 });
|
|
116
|
+
}
|
|
117
|
+
return bytes;
|
|
118
|
+
});
|
|
119
|
+
if (!rootCached)
|
|
120
|
+
persist(rootName, rootBytes, sha256(rootBytes));
|
|
121
|
+
for (const page of pending)
|
|
122
|
+
persist(page.name, page.bytes, page.digest);
|
|
123
|
+
let bootstrapNetworkBytes = 0;
|
|
124
|
+
const prefetched = new Map();
|
|
125
|
+
const loadVerifiedPackage = async (row) => {
|
|
126
|
+
const trusted = candidate.rows.find(item => item.resourceKey === row.resourceKey);
|
|
127
|
+
check(trusted && trusted.packageSha256 === row.packageSha256 && trusted.compressedBytes === row.compressedBytes, 'bootstrap package is outside exact candidate rows');
|
|
128
|
+
const cached = readCache(join(cache, `${row.packageSha256}.tgz`), row.compressedBytes, row.compressedBytes);
|
|
129
|
+
const address = fixture ? url(`free-package-v1-${row.packageSha256}`, `${row.packageSha256}.tgz`) : freePackageSetAssetUrl(apiBase, expected, `packages/${row.packageSha256}.tgz`);
|
|
130
|
+
const bytes = cached ?? await fetchBytes(address, row.compressedBytes);
|
|
131
|
+
const files = verifySelectedPackage(trusted, bytes);
|
|
132
|
+
if (!cached) {
|
|
133
|
+
bootstrapNetworkBytes += bytes.length;
|
|
134
|
+
persist(`${row.packageSha256}.tgz`, bytes, row.packageSha256);
|
|
135
|
+
}
|
|
136
|
+
prefetched.set(row.packageSha256, { bytes, row: trusted, network: !cached });
|
|
137
|
+
return files;
|
|
138
|
+
};
|
|
139
|
+
const resourceKeys = options.resolveResourceKeys
|
|
140
|
+
? await options.resolveResourceKeys(candidate, loadVerifiedPackage)
|
|
141
|
+
: options.resourceKeys;
|
|
142
|
+
check(resourceKeys && resourceKeys.length > 0 && new Set(resourceKeys).size === resourceKeys.length, 'candidate planner must return explicit unique resource keys');
|
|
143
|
+
const selectedRows = planSelectedPackages(candidate, resourceKeys);
|
|
144
|
+
const prefetchedNetworkDigests = new Set([...prefetched].filter(([, value]) => value.network).map(([digest]) => digest));
|
|
145
|
+
const report = await consumeSelectedPackages(candidate, resourceKeys, {
|
|
146
|
+
readCache: row => Promise.resolve(prefetched.get(row.packageSha256)?.bytes ?? readCache(join(cache, `${row.packageSha256}.tgz`), row.compressedBytes, row.compressedBytes)),
|
|
147
|
+
writeCache: (row, bytes) => { persist(`${row.packageSha256}.tgz`, bytes, row.packageSha256); return Promise.resolve(); },
|
|
148
|
+
download: row => fetchBytes(fixture ? url(`free-package-v1-${row.packageSha256}`, `${row.packageSha256}.tgz`) : freePackageSetAssetUrl(apiBase, expected, `packages/${row.packageSha256}.tgz`), row.compressedBytes),
|
|
149
|
+
consume: options.consume,
|
|
150
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
151
|
+
});
|
|
152
|
+
const prefetchedSelectedNetworkCount = selectedRows.filter(row => prefetchedNetworkDigests.has(row.packageSha256)).length;
|
|
153
|
+
return { ...report, networkBytes: report.networkBytes + bootstrapNetworkBytes, cacheHits: report.cacheHits - prefetchedSelectedNetworkCount, metadataNetworkBytes, metadataCacheHits, transferRetryCount };
|
|
154
|
+
}
|
|
155
|
+
/** Resolve a version through its immutable public pointer, then use the same
|
|
156
|
+
* exact-pin package reader. The pointer is cached only after the candidate
|
|
157
|
+
* root and all requested units have passed verification. */
|
|
158
|
+
export async function downloadFreeVersionPackages(options) {
|
|
159
|
+
options.signal?.throwIfAborted();
|
|
160
|
+
check((options.resourceKeys !== undefined) !== (options.resolveResourceKeys !== undefined), 'provide exactly one explicit selection or candidate-based planner');
|
|
161
|
+
if (options.resourceKeys)
|
|
162
|
+
check(options.resourceKeys.length > 0 && new Set(options.resourceKeys).size === options.resourceKeys.length, 'explicit unique resource selection required');
|
|
163
|
+
check(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:alpha|beta)(?:\.(?:0|[1-9]\d*))?)?$/.test(options.fullVersion), 'exact Free version required');
|
|
164
|
+
let fixture;
|
|
165
|
+
if (options.fixtureOrigin) {
|
|
166
|
+
fixture = new URL(options.fixtureOrigin);
|
|
167
|
+
check(fixture.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(fixture.hostname) && !fixture.username && !fixture.password && !fixture.search && !fixture.hash && fixture.pathname === '/', 'fixture must be an explicit loopback origin');
|
|
168
|
+
}
|
|
169
|
+
const cache = join(options.cacheDir, 'small-pack-v1', 'free');
|
|
170
|
+
const apiBase = resolveFreeApiBase(options.apiBaseUrl, fixture);
|
|
171
|
+
if (options.expectedPackageSetSha256 !== undefined) {
|
|
172
|
+
check(/^[a-f0-9]{64}$/.test(options.expectedPackageSetSha256), 'expected package-set digest must be lowercase SHA-256');
|
|
173
|
+
const identity = await fetchFreePackageSetIdentity({
|
|
174
|
+
version: options.fullVersion,
|
|
175
|
+
packageSetSha256: options.expectedPackageSetSha256,
|
|
176
|
+
apiBase,
|
|
177
|
+
...(fixture ? { fixture } : {}),
|
|
178
|
+
...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
|
|
179
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
180
|
+
});
|
|
181
|
+
const downloadOptions = {
|
|
182
|
+
expected: identity,
|
|
183
|
+
cacheDir: options.cacheDir,
|
|
184
|
+
...(options.resourceKeys ? { resourceKeys: options.resourceKeys } : {}),
|
|
185
|
+
...(options.resolveResourceKeys ? { resolveResourceKeys: options.resolveResourceKeys } : {}),
|
|
186
|
+
consume: options.consume,
|
|
187
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
188
|
+
...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
|
|
189
|
+
...(options.fixtureOrigin ? { fixtureOrigin: options.fixtureOrigin } : {}),
|
|
190
|
+
...(options.apiBaseUrl ? { apiBaseUrl: options.apiBaseUrl } : {}),
|
|
191
|
+
expectedRootSha256: identity.candidateRootSha256,
|
|
192
|
+
expectedRootBytes: identity.candidateRootBytes,
|
|
193
|
+
};
|
|
194
|
+
const report = await downloadFreeSelectedPackages({
|
|
195
|
+
...downloadOptions,
|
|
196
|
+
});
|
|
197
|
+
return { ...report, metadataNetworkBytes: report.metadataNetworkBytes + identity.networkBytes, metadataCacheHits: report.metadataCacheHits };
|
|
198
|
+
}
|
|
199
|
+
const pointerFile = join(cache, `version-${options.fullVersion}.json`);
|
|
200
|
+
let pointerBytes = readCache(pointerFile, 16 * 1024);
|
|
201
|
+
const pointerCached = pointerBytes !== undefined;
|
|
202
|
+
if (!pointerBytes) {
|
|
203
|
+
const tag = `candidate-version-v1-free-${options.fullVersion}`;
|
|
204
|
+
const address = fixture
|
|
205
|
+
? new URL(`/moewolf-dev/moe-icons/releases/download/${tag}/candidate-version-free.json`, fixture).href
|
|
206
|
+
: githubReleaseAssetUrl(tag, 'candidate-version-free.json');
|
|
207
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
208
|
+
options.signal?.throwIfAborted();
|
|
209
|
+
const result = await downloadArtifact(address, {
|
|
210
|
+
maxBytes: 16 * 1024,
|
|
211
|
+
timeoutMs: 120000,
|
|
212
|
+
maxRedirects: 3,
|
|
213
|
+
allowedHosts: fixture ? [fixture.host] : FREE_DOWNLOAD_HOSTS,
|
|
214
|
+
...(fixture ? { allowHttpLoopback: true } : {}),
|
|
215
|
+
userAgent: 'moeicons/small-package',
|
|
216
|
+
}, { ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}), ...(options.signal ? { signal: options.signal } : {}) });
|
|
217
|
+
if (result.ok) {
|
|
218
|
+
pointerBytes = result.bytes;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const transient = result.code === 'NETWORK_ERROR' || result.code === 'HTTP_ERROR' && /\b(?:429|500|502|503|504)\b/.test(result.message);
|
|
222
|
+
if (!transient || attempt === 2)
|
|
223
|
+
throw new Error(`Free candidate version pointer: ${result.code}: ${result.message}`);
|
|
224
|
+
await new Promise((resolve, reject) => {
|
|
225
|
+
const aborted = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', aborted); reject(new Error('Free package download cancelled')); };
|
|
226
|
+
const timer = setTimeout(() => { options.signal?.removeEventListener('abort', aborted); resolve(); }, 250 * (attempt + 1));
|
|
227
|
+
options.signal?.addEventListener('abort', aborted, { once: true });
|
|
228
|
+
if (options.signal?.aborted)
|
|
229
|
+
aborted();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
check(pointerBytes, 'version pointer download returned no bytes');
|
|
234
|
+
const pointer = parseFreeCandidateVersionPointer(pointerBytes, options.fullVersion);
|
|
235
|
+
const { fullVersion, ...downloadOptions } = options;
|
|
236
|
+
const report = await downloadFreeSelectedPackages({
|
|
237
|
+
...downloadOptions,
|
|
238
|
+
expected: {
|
|
239
|
+
fullVersion,
|
|
240
|
+
tier: 'free',
|
|
241
|
+
sourceCommit: pointer.sourceCommit,
|
|
242
|
+
generatorCommit: pointer.generatorCommit,
|
|
243
|
+
packageSetSha256: pointer.packageSetSha256,
|
|
244
|
+
},
|
|
245
|
+
expectedRootSha256: pointer.candidateRootSha256,
|
|
246
|
+
expectedRootBytes: pointer.candidateRootBytes,
|
|
247
|
+
});
|
|
248
|
+
if (!pointerCached) {
|
|
249
|
+
const io = { mkdirSync: (dir) => { fs.mkdirSync(dir, { recursive: true }); }, writeFileSync: fs.writeFileSync, renameSync: fs.renameSync, existsSync: fs.existsSync, rmSync: fs.rmSync, readFileSync: fs.readFileSync, readdirSync: fs.readdirSync };
|
|
250
|
+
cacheArtifact(io, pointerFile, pointerBytes, sha256(pointerBytes));
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...report,
|
|
254
|
+
metadataNetworkBytes: report.metadataNetworkBytes + (pointerCached ? 0 : pointerBytes.length),
|
|
255
|
+
metadataCacheHits: report.metadataCacheHits + (pointerCached ? 1 : 0),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const FREE_API_DEFAULT = 'https://api.moeicons.com';
|
|
259
|
+
const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:alpha|beta)(?:\.(?:0|[1-9]\d*))?)?$/;
|
|
260
|
+
function resolveFreeApiBase(value, fixture) {
|
|
261
|
+
const base = new URL(value ?? fixture?.origin ?? FREE_API_DEFAULT);
|
|
262
|
+
const loopback = base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname);
|
|
263
|
+
check((base.protocol === 'https:' || loopback) && !base.username && !base.password && !base.search && !base.hash && base.pathname === '/', 'API base must be HTTPS or an explicit loopback origin');
|
|
264
|
+
return base;
|
|
265
|
+
}
|
|
266
|
+
function freePackageSetApiUrl(apiBase, query) {
|
|
267
|
+
const url = new URL('/v1/icon-library/free/package-set', apiBase);
|
|
268
|
+
url.search = query.toString();
|
|
269
|
+
return url.href;
|
|
270
|
+
}
|
|
271
|
+
function freePackageSetAssetUrl(apiBase, identity, path) {
|
|
272
|
+
const url = new URL(`/v1/icon-library/free/package-set/${path}`, apiBase);
|
|
273
|
+
url.searchParams.set('version', identity.fullVersion);
|
|
274
|
+
url.searchParams.set('packageSetSha256', identity.packageSetSha256);
|
|
275
|
+
return url.href;
|
|
276
|
+
}
|
|
277
|
+
async function fetchFreePackageSetIdentity(input) {
|
|
278
|
+
const query = new URLSearchParams({ version: input.version });
|
|
279
|
+
if (input.packageSetSha256)
|
|
280
|
+
query.set('packageSetSha256', input.packageSetSha256);
|
|
281
|
+
let address;
|
|
282
|
+
if (input.fixture) {
|
|
283
|
+
address = input.version === 'stable'
|
|
284
|
+
? new URL('/v1/icon-library/free/package-set?version=stable', input.fixture).href
|
|
285
|
+
: new URL(`/v1/icon-library/free/package-set?version=${encodeURIComponent(input.version)}&packageSetSha256=${encodeURIComponent(input.packageSetSha256 ?? '')}`, input.fixture).href;
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
address = freePackageSetApiUrl(input.apiBase, query);
|
|
289
|
+
const result = await downloadArtifact(address, {
|
|
290
|
+
maxBytes: 16 * 1024,
|
|
291
|
+
timeoutMs: 120000,
|
|
292
|
+
maxRedirects: 0,
|
|
293
|
+
allowedHosts: input.fixture ? [input.fixture.host] : [input.apiBase.host],
|
|
294
|
+
...(input.fixture || input.apiBase.protocol === 'http:' ? { allowHttpLoopback: true } : {}),
|
|
295
|
+
userAgent: 'moeicons/small-package',
|
|
296
|
+
}, { ...(input.fetchFn ? { fetchFn: input.fetchFn } : {}), ...(input.signal ? { signal: input.signal } : {}) });
|
|
297
|
+
if (!result.ok)
|
|
298
|
+
throw new Error(`Free package-set identity: ${result.code}: ${result.message}`);
|
|
299
|
+
let raw;
|
|
300
|
+
try {
|
|
301
|
+
raw = JSON.parse(Buffer.from(result.bytes).toString('utf8'));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new Error('Free package-set identity: invalid JSON');
|
|
305
|
+
}
|
|
306
|
+
check(raw !== null && typeof raw === 'object' && !Array.isArray(raw), 'identity object required');
|
|
307
|
+
const value = raw;
|
|
308
|
+
const keys = ['schemaVersion', 'tier', 'fullVersion', 'sourceCommit', 'generatorCommit', 'packageSetSha256', 'candidateRootSha256', 'candidateRootBytes', 'updatedAt'];
|
|
309
|
+
check(Object.keys(value).sort().join(',') === [...keys].sort().join(','), 'identity fields differ from the Free package-set contract');
|
|
310
|
+
check(value.schemaVersion === 1 && value.tier === 'free' && typeof value.fullVersion === 'string' && VERSION_PATTERN.test(value.fullVersion), 'Free version identity invalid');
|
|
311
|
+
check(input.version === 'stable' || value.fullVersion === input.version, 'identity version differs from request');
|
|
312
|
+
check(typeof value.sourceCommit === 'string' && /^[a-f0-9]{40}$/.test(value.sourceCommit), 'Source commit invalid');
|
|
313
|
+
check(typeof value.generatorCommit === 'string' && /^[a-f0-9]{40}$/.test(value.generatorCommit), 'Generator commit invalid');
|
|
314
|
+
check(typeof value.packageSetSha256 === 'string' && /^[a-f0-9]{64}$/.test(value.packageSetSha256), 'package-set digest invalid');
|
|
315
|
+
check(input.packageSetSha256 === undefined || value.packageSetSha256 === input.packageSetSha256, 'identity package-set digest differs from request');
|
|
316
|
+
check(typeof value.candidateRootSha256 === 'string' && /^[a-f0-9]{64}$/.test(value.candidateRootSha256), 'candidate root digest invalid');
|
|
317
|
+
check(Number.isSafeInteger(value.candidateRootBytes) && Number(value.candidateRootBytes) > 0 && Number(value.candidateRootBytes) <= 1024 ** 2, 'candidate root size invalid');
|
|
318
|
+
check(typeof value.updatedAt === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value.updatedAt) && !Number.isNaN(Date.parse(value.updatedAt)), 'identity update timestamp invalid');
|
|
319
|
+
return { ...value, networkBytes: result.bytes.length };
|
|
320
|
+
}
|
|
321
|
+
export async function fetchFreeStablePackageSet(options = {}) {
|
|
322
|
+
let fixture;
|
|
323
|
+
if (options.fixtureOrigin) {
|
|
324
|
+
fixture = new URL(options.fixtureOrigin);
|
|
325
|
+
check(fixture.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(fixture.hostname) && !fixture.username && !fixture.password && !fixture.search && !fixture.hash && fixture.pathname === '/', 'fixture must be an explicit loopback origin');
|
|
326
|
+
}
|
|
327
|
+
const apiBase = resolveFreeApiBase(options.apiBaseUrl, fixture);
|
|
328
|
+
return fetchFreePackageSetIdentity({ version: 'stable', apiBase, ...(fixture ? { fixture } : {}), ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}), ...(options.signal ? { signal: options.signal } : {}) });
|
|
329
|
+
}
|
|
330
|
+
export async function downloadFreeStablePackages(options) {
|
|
331
|
+
const identity = await fetchFreeStablePackageSet({ ...(options.apiBaseUrl ? { apiBaseUrl: options.apiBaseUrl } : {}), ...(options.fixtureOrigin ? { fixtureOrigin: options.fixtureOrigin } : {}), ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}), ...(options.signal ? { signal: options.signal } : {}) });
|
|
332
|
+
const { apiBaseUrl, fixtureOrigin, fetchFn, signal, ...rest } = options;
|
|
333
|
+
const report = await downloadFreeSelectedPackages({
|
|
334
|
+
...rest,
|
|
335
|
+
...(apiBaseUrl ? { apiBaseUrl } : {}),
|
|
336
|
+
...(fixtureOrigin ? { fixtureOrigin } : {}),
|
|
337
|
+
...(fetchFn ? { fetchFn } : {}),
|
|
338
|
+
...(signal ? { signal } : {}),
|
|
339
|
+
expected: identity,
|
|
340
|
+
expectedRootSha256: identity.candidateRootSha256,
|
|
341
|
+
expectedRootBytes: identity.candidateRootBytes,
|
|
342
|
+
});
|
|
343
|
+
return { ...report, metadataNetworkBytes: report.metadataNetworkBytes + identity.networkBytes };
|
|
344
|
+
}
|