@moewolf/moe-icons-cli 0.0.10 → 0.0.14

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.
Files changed (56) hide show
  1. package/README.md +27 -19
  2. package/dist/cli.js +124 -146
  3. package/dist/config-package/generated/SOURCE.json +3 -3
  4. package/dist/config-package/generated/moeicons-config.schema.json +1 -1
  5. package/dist/core/bitmap-shard-budget.d.ts +2 -2
  6. package/dist/core/bitmap-shard-budget.js +1 -1
  7. package/dist/core/bootstrap.js +5 -3
  8. package/dist/core/candidate-version-pointer.d.ts +13 -0
  9. package/dist/core/candidate-version-pointer.js +37 -0
  10. package/dist/core/config-package-plan.d.ts +25 -0
  11. package/dist/core/config-package-plan.js +156 -0
  12. package/dist/core/doctor.js +4 -0
  13. package/dist/core/free-download.js +34 -4
  14. package/dist/core/free-small-package-download.d.ts +67 -0
  15. package/dist/core/free-small-package-download.js +344 -0
  16. package/dist/core/generate.js +49 -33
  17. package/dist/core/install.d.ts +8 -2
  18. package/dist/core/install.js +70 -0
  19. package/dist/core/library-update.d.ts +6 -1
  20. package/dist/core/library-update.js +54 -0
  21. package/dist/core/manage.d.ts +4 -0
  22. package/dist/core/manage.js +4 -0
  23. package/dist/core/metadata-sync.js +119 -1
  24. package/dist/core/pro-download.js +19 -2
  25. package/dist/core/pro-install.d.ts +10 -4
  26. package/dist/core/pro-install.js +47 -0
  27. package/dist/core/pro-resources.d.ts +23 -0
  28. package/dist/core/pro-resources.js +51 -0
  29. package/dist/core/pro-selected-download.js +12 -2
  30. package/dist/core/pro-small-package-download.d.ts +61 -0
  31. package/dist/core/pro-small-package-download.js +301 -0
  32. package/dist/core/release-descriptor.js +6 -2
  33. package/dist/core/selected-resources.js +2 -2
  34. package/dist/core/small-pack/PIN.json +11 -0
  35. package/dist/core/small-pack/candidate-set-v1.schema.json +187 -0
  36. package/dist/core/small-pack/candidate-set.cjs +286 -0
  37. package/dist/core/small-pack/candidate-set.d.cts +12 -0
  38. package/dist/core/small-pack/tar-read.cjs +220 -0
  39. package/dist/core/small-pack/tar-read.d.cts +2 -0
  40. package/dist/core/small-package-install.d.ts +40 -0
  41. package/dist/core/small-package-install.js +252 -0
  42. package/dist/core/small-package-selection.d.ts +27 -0
  43. package/dist/core/small-package-selection.js +114 -0
  44. package/dist/core/update-policy.d.ts +1 -0
  45. package/dist/core/update-policy.js +10 -3
  46. package/dist/mcp/services.js +1 -1
  47. package/dist/project/config.js +1 -1
  48. package/dist/project/install-metadata.d.ts +2 -1
  49. package/dist/project/install-metadata.js +6 -3
  50. package/dist/project/install.d.ts +11 -2
  51. package/dist/project/install.js +104 -12
  52. package/dist/project/package-install-metadata.d.ts +31 -0
  53. package/dist/project/package-install-metadata.js +99 -0
  54. package/dist/project/tar-gz.d.ts +4 -8
  55. package/dist/project/tar-gz.js +6 -10
  56. package/package.json +11 -11
@@ -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
+ }
@@ -13,6 +13,7 @@ import { artifactCachePath } from "./free-download.js";
13
13
  import { resolveThemes } from "../generator/theme-resolve.js";
14
14
  import { loadPinnedBitmapShardAssets } from "./bitmap-shard-resolver.js";
15
15
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
16
+ import { parsePackageInstallMetadata, serializePackageInstallMetadata } from "../project/package-install-metadata.js";
16
17
  import { withProjectLockSync } from "../project/project-lock.js";
17
18
  import { allowLocalTestFromEnv } from "./local-test-env.js";
18
19
  import { resolveIconTheme, themeHasIcon } from "./icon-selection.js";
@@ -68,7 +69,7 @@ export function loadInstalledCatalogState(projectRoot, fs_, opts = {}) {
68
69
  const rawMetadata = fs_.readFileSync(metadataPath, "utf8");
69
70
  if (typeof rawMetadata !== "string")
70
71
  throw new Error("metadata is not text");
71
- metadata = parseInstallMetadata(rawMetadata, opts);
72
+ metadata = parseInstallMetadata(rawMetadata, opts) ?? parsePackageInstallMetadata(rawMetadata);
72
73
  }
73
74
  catch (error) {
74
75
  return {
@@ -124,24 +125,19 @@ function missingArchiveMessage(config) {
124
125
  }
125
126
  return "bitmap themes require a downloaded free artifact; run `moeicons install` or set MOEICONS_BITMAP_ARCHIVE";
126
127
  }
127
- function readInstallMetadata(projectRoot, readFileSync, existsSync) {
128
+ function readInstallMetadata(projectRoot, readFileSync, existsSync, opts = {}) {
128
129
  const path = join(projectRoot, ".moeicons", "install-metadata.json");
129
130
  if (!existsSync(path))
130
131
  return undefined;
131
132
  try {
132
- const parsed = JSON.parse(readFileSync(path, "utf8"));
133
- return {
134
- ...(typeof parsed.artifactVersion === "string"
135
- ? { artifactVersion: parsed.artifactVersion }
136
- : {}),
137
- ...(typeof parsed.artifactSha256 === "string"
138
- ? { artifactSha256: parsed.artifactSha256 }
139
- : {}),
140
- ...(typeof parsed.target === "string" &&
141
- ["react", "vue", "vanilla", "assets"].includes(parsed.target)
142
- ? { target: parsed.target }
143
- : {}),
144
- };
133
+ const raw = readFileSync(path, "utf8");
134
+ const parsed = parseInstallMetadata(raw, opts);
135
+ if (parsed)
136
+ return { artifactVersion: parsed.artifactVersion, artifactSha256: parsed.artifactSha256, target: parsed.target };
137
+ const packageMetadata = parsePackageInstallMetadata(raw);
138
+ if (packageMetadata)
139
+ return { artifactVersion: packageMetadata.candidate.fullVersion, target: packageMetadata.target };
140
+ return undefined;
145
141
  }
146
142
  catch {
147
143
  return undefined;
@@ -189,10 +185,17 @@ function mergePinnedBitmapShardAssets(archiveFiles, effectiveConfig, sourceCatal
189
185
  errors: ["bitmap shards require an installed pro artifact; run `moeicons install` first"],
190
186
  };
191
187
  }
192
- const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
188
+ const rawMetadata = fs_.readFileSync(metadataPath, "utf8");
189
+ const metadata = parseInstallMetadata(rawMetadata, {
193
190
  allowLocalTest: allowLocalTestFromEnv(env),
194
191
  });
195
192
  if (!metadata) {
193
+ if (parsePackageInstallMetadata(rawMetadata)) {
194
+ return {
195
+ ok: false,
196
+ errors: ["selected bitmap packages are missing for this configuration; run `moeicons install` to download the configured icons, format and imageSize"],
197
+ };
198
+ }
196
199
  return {
197
200
  ok: false,
198
201
  errors: ["install metadata is invalid; run 'moeicons install' to repair or reinstall"],
@@ -259,10 +262,10 @@ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal)
259
262
  }
260
263
  const selectionMetadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
261
264
  if (fs_.existsSync(selectionMetadataPath)) {
262
- const pinned = parseInstallMetadata(fs_.readFileSync(selectionMetadataPath, "utf8"), {
263
- allowLocalTest: allowLocalTestFromEnv(env),
264
- });
265
- if (pinned?.delivery?.mode === "icons") {
265
+ const raw = fs_.readFileSync(selectionMetadataPath, "utf8");
266
+ const parseOptions = { allowLocalTest: allowLocalTestFromEnv(env) };
267
+ const pinned = parseInstallMetadata(raw, parseOptions) ?? parsePackageInstallMetadata(raw);
268
+ if (pinned?.schemaVersion === 1 && pinned.delivery?.mode === "icons") {
266
269
  const files = {};
267
270
  for (const [name, hash] of Object.entries(pinned.managedFiles)) {
268
271
  if (!name.startsWith(".moeicons/artifact/"))
@@ -280,8 +283,23 @@ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal)
280
283
  }
281
284
  return { ok: true, files };
282
285
  }
286
+ if (pinned?.schemaVersion === 2) {
287
+ const files = {};
288
+ for (const [name, hash] of Object.entries(pinned.managedFiles)) {
289
+ if (!name.startsWith(".moeicons/artifact/"))
290
+ continue;
291
+ const target = safeManagedPath(projectRoot, name).target;
292
+ if (!fs_.existsSync(target))
293
+ return { ok: false, reason: `selected resource missing: ${name}; run moeicons install` };
294
+ const bytes = readBinaryFile(fs_, target);
295
+ if (sha256Bytes(bytes) !== hash)
296
+ return { ok: false, reason: `selected resource modified: ${name}; preserve your edit before reinstalling` };
297
+ files[name.slice(".moeicons/artifact/".length)] = bytes;
298
+ }
299
+ return { ok: true, files };
300
+ }
283
301
  }
284
- const meta = readInstallMetadata(projectRoot, fs_.readFileSync, fs_.existsSync);
302
+ const meta = readInstallMetadata(projectRoot, fs_.readFileSync, fs_.existsSync, { allowLocalTest: allowLocalTestFromEnv(env) });
285
303
  if (meta?.target) {
286
304
  const subtreeRoot = join(projectRoot, ".moeicons", "artifact", meta.target);
287
305
  if (fs_.existsSync(subtreeRoot)) {
@@ -356,7 +374,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
356
374
  const plannedMetadataText = fs_.existsSync(plannedMetadataPath)
357
375
  ? fs_.readFileSync(plannedMetadataPath, "utf8")
358
376
  : undefined;
359
- const installedMeta = readInstallMetadata(project.root, fs_.readFileSync, fs_.existsSync);
377
+ const installedMeta = readInstallMetadata(project.root, fs_.readFileSync, fs_.existsSync, { allowLocalTest });
360
378
  if (!installedMeta) {
361
379
  return {
362
380
  ok: false,
@@ -450,12 +468,12 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
450
468
  ok: false,
451
469
  reason: "managed install metadata is missing; run 'moeicons install' to repair or reinstall",
452
470
  };
453
- const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
454
- allowLocalTest,
455
- });
456
- if (fs_.readFileSync(metadataPath, "utf8") !== plannedMetadataText)
471
+ const currentMetadataText = fs_.readFileSync(metadataPath, "utf8");
472
+ const metadata = parseInstallMetadata(currentMetadataText, { allowLocalTest }) ?? parsePackageInstallMetadata(currentMetadataText);
473
+ if (currentMetadataText !== plannedMetadataText)
457
474
  return { ok: false, reason: "installation changed while preparing generate; retry" };
458
- if (!metadata || metadata.tier !== loaded.config.tier)
475
+ const metadataTier = metadata?.schemaVersion === 1 ? metadata.tier : metadata?.candidate.tier;
476
+ if (!metadata || metadataTier !== loaded.config.tier)
459
477
  return {
460
478
  ok: false,
461
479
  reason: "managed install metadata is invalid or does not match config tier; run 'moeicons install' to repair or reinstall",
@@ -521,11 +539,9 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
521
539
  nextManaged[managedPath] = hash;
522
540
  for (const [path, content] of Object.entries(generated))
523
541
  nextManaged[path] = sha256Bytes(content);
524
- const nextMetadata = {
525
- ...metadata,
526
- managedFiles: nextManaged,
527
- generatedOutputDir: loaded.config.outputDir,
528
- };
542
+ const nextMetadataText = metadata.schemaVersion === 1
543
+ ? serializeInstallMetadata({ ...metadata, managedFiles: nextManaged, generatedOutputDir: loaded.config.outputDir })
544
+ : serializePackageInstallMetadata({ ...metadata, managedFiles: nextManaged, generatedOutputDir: loaded.config.outputDir });
529
545
  const writes = { ...generated };
530
546
  for (const file of sideFiles) {
531
547
  const absolute = resolve(file.path);
@@ -534,7 +550,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
534
550
  return { ok: false, reason: `side file escapes project: ${file.path}` };
535
551
  writes[rel] = file.content;
536
552
  }
537
- writes[".moeicons/install-metadata.json"] = serializeInstallMetadata(nextMetadata);
553
+ writes[".moeicons/install-metadata.json"] = nextMetadataText;
538
554
  const stale = Object.keys(metadata.managedFiles).filter((path) => (path.startsWith(outputPrefix) || path.startsWith(previousPrefix)) &&
539
555
  !(path in generated));
540
556
  const expectedSha256 = {
@@ -13,14 +13,20 @@ export type InstallResult = {
13
13
  readonly planItems: number;
14
14
  readonly config: string;
15
15
  readonly artifactVersion: string;
16
- readonly descriptorSha256: string;
16
+ readonly descriptorSha256?: string;
17
+ readonly candidateRootSha256?: string;
18
+ readonly packageSetSha256?: string;
19
+ readonly packageCount?: number;
17
20
  readonly catalogSha256: string;
18
21
  readonly metadataSha256: string;
19
22
  readonly cacheHit: boolean;
20
- readonly downloadMode?: "icons" | "full";
23
+ readonly downloadMode?: "icons" | "full" | "packages";
21
24
  readonly downloadNotes?: readonly string[];
22
25
  readonly networkBytes?: number;
23
26
  readonly selectedFiles?: number;
27
+ readonly cacheHits?: number;
28
+ readonly metadataCacheHits?: number;
29
+ readonly transferRetryCount?: number;
24
30
  } | {
25
31
  readonly ok: false;
26
32
  readonly reason: "no-project";
@@ -9,6 +9,7 @@ import { withProjectLock } from "../project/project-lock.js";
9
9
  import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
10
10
  import { parseCatalog } from "../catalog/catalog.js";
11
11
  import { posix } from "node:path";
12
+ import { runSmallPackageInstall } from "./small-package-install.js";
12
13
  /**
13
14
  * P0-3: the project `types.ts` must re-export a type that actually exists in the
14
15
  * installed `moe-icons` package. Assets have no component type, so it is empty.
@@ -90,6 +91,75 @@ export async function runInstallUseCase(context, deps, options) {
90
91
  message: `config target is "${config.config.target}" but install target is "${target}"; set target to "${target}" in moeicons.config before installing`,
91
92
  };
92
93
  }
94
+ // Production Free installs use the immutable package-set pointer and exact
95
+ // configured dependency closure. Aggregate archives remain available only to
96
+ // the isolated local archive-fixture harness while old fixtures are retired.
97
+ if (!deps.download.fixtureDir) {
98
+ if (config.kind !== "ok") {
99
+ return { ok: false, reason: "validation", message: "run 'moeicons init' and select icons/themes before installing the package set" };
100
+ }
101
+ if (config.config.downloadMode === "full") {
102
+ return { ok: false, reason: "validation", message: "full archive installs are disabled; set downloadMode to icons and select the required resources" };
103
+ }
104
+ if (options.expectedDescriptorSha256) {
105
+ return { ok: false, reason: "validation", message: "legacy descriptor pins cannot identify a package-set candidate; retry through the package-set update command" };
106
+ }
107
+ try {
108
+ const result = await runSmallPackageInstall({
109
+ projectRoot: project.root,
110
+ ...(options.sourceVersion ? { fullVersion: options.sourceVersion } : {}),
111
+ ...(context.env.MOEICONS_API_BASE_URL ? { apiBaseUrl: context.env.MOEICONS_API_BASE_URL } : {}),
112
+ config: config.config,
113
+ plannedConfigDocument,
114
+ fs: deps.fs,
115
+ cacheDir: deps.download.cacheDir,
116
+ now: context.now,
117
+ ...(context.signal ? { signal: context.signal } : {}),
118
+ ...(deps.download.fetchFn ? { fetchFn: deps.download.fetchFn } : {}),
119
+ ...(deps.download.fixtureBaseUrl ? { fixtureOrigin: deps.download.fixtureBaseUrl } : {}),
120
+ });
121
+ return {
122
+ ok: true,
123
+ projectRoot: project.root,
124
+ packageManager: project.packageManager,
125
+ group: "free",
126
+ target,
127
+ artifactBytes: result.artifactBytes,
128
+ planItems: result.planItems,
129
+ config: config.kind,
130
+ artifactVersion: result.artifactVersion,
131
+ candidateRootSha256: result.candidateRootSha256,
132
+ packageSetSha256: result.packageSetSha256,
133
+ packageCount: result.packageCount,
134
+ catalogSha256: result.catalogSha256,
135
+ metadataSha256: result.metadataSha256,
136
+ cacheHit: result.cacheHit,
137
+ downloadMode: "packages",
138
+ downloadNotes: result.fallbacks.map((fallback) => `Theme fallback: ${fallback}`),
139
+ networkBytes: result.networkBytes,
140
+ selectedFiles: result.selectedFiles,
141
+ cacheHits: result.cacheHits,
142
+ metadataCacheHits: result.metadataCacheHits,
143
+ transferRetryCount: result.transferRetryCount,
144
+ };
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ if (context.signal?.aborted || /CANCELLED|cancelled/i.test(message))
149
+ return { ok: false, reason: "cancelled", message };
150
+ if (/ENOSPC|no space left/i.test(message))
151
+ return { ok: false, reason: "disk-full", message };
152
+ if (/HTTP_ERROR[^\n]*\b404\b/i.test(message))
153
+ return { ok: false, reason: "not-found", message };
154
+ if (/HTTP_ERROR[^\n]*\b5\d\d\b|NETWORK_ERROR|network|fetch failed|timed out/i.test(message))
155
+ return { ok: false, reason: "network", message };
156
+ if (/checksum|SHA-256|digest mismatch|differs from immutable version pointer/i.test(message))
157
+ return { ok: false, reason: "checksum-mismatch", message };
158
+ if (/config changed|unsafe|invalid|requires downloadMode|candidate-set:|config package plan:|small package install currently/i.test(message))
159
+ return { ok: false, reason: "validation", message };
160
+ return { ok: false, reason: "write-failed", message };
161
+ }
162
+ }
93
163
  const downloaded = await downloadFreeRelease({ ...deps.download, signal: context.signal }, options.sourceVersion ?? bundledSourceVersion(), config.kind === "ok"
94
164
  ? {
95
165
  config: config.config,
@@ -17,12 +17,17 @@ export declare function runLibraryUpdateUseCase(context: CommandContext, deps: L
17
17
  readonly tier: "free" | "pro";
18
18
  readonly version: string;
19
19
  readonly descriptorSha256: string;
20
+ readonly packageSetSha256?: string;
20
21
  }): Promise<{
21
22
  readonly projectRoot: string;
22
23
  readonly artifactVersion: string;
23
24
  readonly files: readonly string[];
24
- readonly downloadMode: "icons" | "full";
25
+ readonly downloadMode: "icons" | "full" | "packages";
25
26
  readonly downloadNotes: readonly string[];
26
27
  readonly networkBytes?: number;
27
28
  readonly selectedFiles?: number;
29
+ readonly packageCount?: number;
30
+ readonly cacheHits?: number;
31
+ readonly metadataCacheHits?: number;
32
+ readonly transferRetryCount?: number;
28
33
  }>;