@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,252 @@
1
+ import { constants, closeSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { dirname, posix } from "node:path";
4
+ import { canonicalBytes, sha256 } from "./small-pack/candidate-set.cjs";
5
+ import { downloadFreeStablePackages, downloadFreeVersionPackages } from "./free-small-package-download.js";
6
+ import { downloadProStablePackages, downloadProVersionPackages } from "./pro-small-package-download.js";
7
+ import { packageTargetFiles, planConfigPackagesFromMetadata } from "./config-package-plan.js";
8
+ import { createInstallPlan, executeInstallPlan, safeManagedPath } from "../project/install.js";
9
+ import { loadConfigDocument } from "../project/config.js";
10
+ import { serializePackageInstallMetadata } from "../project/package-install-metadata.js";
11
+ import { sha256Bytes } from "../project/install-metadata.js";
12
+ import { withProjectLock } from "../project/project-lock.js";
13
+ function targetSubtreeDigest(files) {
14
+ const hash = createHash("sha256");
15
+ let fileCount = 0;
16
+ let byteCount = 0;
17
+ const namesAt = (prefix) => {
18
+ const names = new Set();
19
+ for (const rel of files.keys()) {
20
+ if (!prefix || rel.startsWith(`${prefix}/`)) {
21
+ const rest = prefix ? rel.slice(prefix.length + 1) : rel;
22
+ const slash = rest.indexOf("/");
23
+ names.add(slash === -1 ? rest : rest.slice(0, slash));
24
+ }
25
+ }
26
+ return [...names].sort((a, b) => a.localeCompare(b));
27
+ };
28
+ const appendFile = (entry) => {
29
+ const descriptor = openSync(entry.absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
30
+ try {
31
+ if (!fstatSync(descriptor).isFile())
32
+ throw new Error("staged target output is not a regular file");
33
+ const buffer = Buffer.allocUnsafe(64 * 1024);
34
+ let position = 0;
35
+ let count = 1;
36
+ while (count > 0) {
37
+ count = readSync(descriptor, buffer, 0, buffer.length, position);
38
+ if (count === 0)
39
+ break;
40
+ hash.update(buffer.subarray(0, count));
41
+ position += count;
42
+ }
43
+ if (position !== entry.byteSize)
44
+ throw new Error("staged target output changed while calculating install identity");
45
+ byteCount += position;
46
+ }
47
+ finally {
48
+ closeSync(descriptor);
49
+ }
50
+ };
51
+ const walk = (prefix) => {
52
+ for (const name of namesAt(prefix)) {
53
+ const relative = prefix ? `${prefix}/${name}` : name;
54
+ const entry = files.get(relative);
55
+ if (entry) {
56
+ hash.update(relative).update("\0");
57
+ appendFile(entry);
58
+ hash.update("\0");
59
+ fileCount += 1;
60
+ }
61
+ else
62
+ walk(relative);
63
+ }
64
+ };
65
+ walk("");
66
+ return { sha256: hash.digest("hex"), fileCount, byteCount };
67
+ }
68
+ function destination(area, target, path) {
69
+ if (area === "target")
70
+ return `.moeicons/artifact/${target}/${path}`;
71
+ if (area === "assets")
72
+ return `.moeicons/artifact/assets/${path}`;
73
+ return `.moeicons/package-metadata/${path}`;
74
+ }
75
+ export async function runSmallPackageInstall(options) {
76
+ if (options.config.downloadMode === "full")
77
+ throw new Error("package installs require downloadMode=icons; full archive delivery is disabled");
78
+ if (options.config.tier === "pro" && (!options.proAccessToken || options.proAccessToken.length > 8192 || /[\r\n]/.test(options.proAccessToken))) {
79
+ throw new Error("Pro package installs require an authenticated access token");
80
+ }
81
+ if (options.config.icons.length === 0 || Object.keys(options.config.themes).length === 0)
82
+ throw new Error("configure at least one icon and theme before installing selected packages");
83
+ const operationId = randomUUID();
84
+ const stageRoot = `.moeicons/.package-stage-${operationId}`;
85
+ const stageRootPath = safeManagedPath(options.projectRoot, stageRoot).target;
86
+ const controlPath = safeManagedPath(options.projectRoot, ".moeicons").target;
87
+ let packagePlan;
88
+ let candidateRoot;
89
+ let installedFileCount = 0;
90
+ let nextFile = 0;
91
+ const installFiles = Object.create(null);
92
+ const targetFiles = new Map();
93
+ const assetManifestEntries = [];
94
+ const stageFile = (installPath, relativeToTarget, bytes) => {
95
+ if (Object.hasOwn(installFiles, installPath))
96
+ throw new Error(`selected packages produce a duplicate install path: ${installPath}`);
97
+ const stagedRelative = `${stageRoot}/files/${String(nextFile++).padStart(6, "0")}`;
98
+ const stagedPath = safeManagedPath(options.projectRoot, stagedRelative);
99
+ options.fs.mkdirSync(dirname(stagedPath.target), { recursive: true });
100
+ options.fs.writeFileSync(stagedPath.target, bytes);
101
+ const digest = sha256Bytes(bytes);
102
+ const staged = { kind: "staged-file", path: stagedRelative, sha256: digest };
103
+ installFiles[installPath] = staged;
104
+ installedFileCount += 1;
105
+ if (relativeToTarget !== undefined) {
106
+ if (targetFiles.has(relativeToTarget))
107
+ throw new Error(`selected packages produce a duplicate target file: ${relativeToTarget}`);
108
+ targetFiles.set(relativeToTarget, { absolutePath: stagedPath.target, byteSize: bytes.byteLength });
109
+ }
110
+ };
111
+ options.fs.mkdirSync(controlPath, { recursive: true });
112
+ options.fs.mkdirSync(stageRootPath, { recursive: false });
113
+ try {
114
+ const downloadOptions = {
115
+ cacheDir: options.cacheDir,
116
+ ...(options.apiBaseUrl ? { apiBaseUrl: options.apiBaseUrl } : {}),
117
+ ...(options.signal ? { signal: options.signal } : {}),
118
+ ...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
119
+ ...(options.fixtureOrigin ? { fixtureOrigin: options.fixtureOrigin } : {}),
120
+ resolveResourceKeys: async (candidate, loadVerifiedPackage) => {
121
+ candidateRoot = candidate;
122
+ packagePlan = await planConfigPackagesFromMetadata(options.config, candidate, loadVerifiedPackage);
123
+ return packagePlan.resourceKeys;
124
+ },
125
+ consume: (row, verifiedFiles) => {
126
+ const mapped = packageTargetFiles(row, verifiedFiles, options.config.target);
127
+ for (const [relative, bytes] of Object.entries(mapped.files)) {
128
+ const installPath = destination(mapped.area, options.config.target, relative);
129
+ if (mapped.area === "assets") {
130
+ assetManifestEntries.push({ path: relative, size: bytes.byteLength, sha256: sha256Bytes(bytes) });
131
+ }
132
+ const targetRelative = mapped.area === "target" || options.config.target === "assets" && mapped.area === "assets"
133
+ ? relative
134
+ : undefined;
135
+ stageFile(installPath, targetRelative, bytes);
136
+ }
137
+ return Promise.resolve();
138
+ },
139
+ };
140
+ const report = options.config.tier === "free"
141
+ ? options.fullVersion
142
+ ? await downloadFreeVersionPackages({ ...downloadOptions, fullVersion: options.fullVersion, ...(options.expectedPackageSetSha256 ? { expectedPackageSetSha256: options.expectedPackageSetSha256 } : {}) })
143
+ : await downloadFreeStablePackages(downloadOptions)
144
+ : options.fullVersion && options.expectedPackageSetSha256
145
+ ? await downloadProVersionPackages({ ...downloadOptions, accessToken: options.proAccessToken, fullVersion: options.fullVersion, expectedPackageSetSha256: options.expectedPackageSetSha256 })
146
+ : options.fullVersion || options.expectedPackageSetSha256
147
+ ? (() => { throw new Error("Pro package-set updates require both exact version and package-set SHA-256"); })()
148
+ : await downloadProStablePackages({ ...downloadOptions, accessToken: options.proAccessToken });
149
+ if (!packagePlan || !candidateRoot)
150
+ throw new Error("candidate planner did not produce an install plan");
151
+ if (options.signal?.aborted)
152
+ throw new Error("package install cancelled");
153
+ const catalogJson = `${JSON.stringify(packagePlan.catalog, null, 2)}\n`;
154
+ const catalogSha256 = sha256Bytes(catalogJson);
155
+ installFiles[".moeicons/catalog.json"] = catalogJson;
156
+ if (assetManifestEntries.length > 0) {
157
+ const assets = assetManifestEntries.sort((a, b) => a.path.localeCompare(b.path, "en"));
158
+ const manifestBytes = Buffer.from(`${JSON.stringify({ schemaVersion: 1, assets }, null, 2)}\n`);
159
+ if (options.config.target === "assets")
160
+ stageFile(".moeicons/artifact/assets/manifest.json", "manifest.json", manifestBytes);
161
+ else
162
+ installFiles[".moeicons/artifact/assets/manifest.json"] = manifestBytes;
163
+ }
164
+ installFiles[".moeicons/artifact/package.json"] = '{"private":true,"type":"module","sideEffects":false}\n';
165
+ const outputDir = options.config.outputDir.replace(/\\/g, "/").replace(/\/$/, "");
166
+ installFiles[`${outputDir}/types.ts`] = smallPackageTypesReexport(options.config.target, outputDir);
167
+ installFiles[`${outputDir}/.moeicons-${options.config.tier}.marker`] = `${options.config.tier}\n`;
168
+ const tree = targetSubtreeDigest(targetFiles);
169
+ if (tree.fileCount === 0)
170
+ throw new Error("selected candidate contains no files for the configured target");
171
+ const candidateRootSha256 = sha256(canonicalBytes(candidateRoot.root));
172
+ const packagePins = packagePlan.packages
173
+ .map((row) => ({ resourceKey: row.resourceKey, packageSha256: row.packageSha256, dependencies: [...row.dependencies] }))
174
+ .sort((a, b) => a.resourceKey.localeCompare(b.resourceKey, "en"));
175
+ const metadata = {
176
+ schemaVersion: 2,
177
+ candidate: {
178
+ schemaVersion: 1,
179
+ tier: options.config.tier,
180
+ fullVersion: candidateRoot.root.fullVersion,
181
+ sourceCommit: candidateRoot.root.sourceCommit,
182
+ generatorCommit: candidateRoot.root.generatorCommit,
183
+ packageSetSha256: candidateRoot.root.packageSetSha256,
184
+ candidateRootSha256,
185
+ },
186
+ target: options.config.target,
187
+ resourceKeys: [...packagePlan.resourceKeys].sort((a, b) => a.localeCompare(b, "en")),
188
+ packages: packagePins,
189
+ catalogSha256,
190
+ installedAt: options.now().toISOString(),
191
+ managedFiles: Object.fromEntries(Object.entries(installFiles).map(([path, content]) => [
192
+ path,
193
+ typeof content === "object" && "kind" in content ? content.sha256 : sha256Bytes(content),
194
+ ])),
195
+ targetSha256: tree.sha256,
196
+ targetFileCount: tree.fileCount,
197
+ targetByteCount: tree.byteCount,
198
+ generatedOutputDir: outputDir,
199
+ };
200
+ const metadataText = serializePackageInstallMetadata(metadata);
201
+ installFiles[".moeicons/install-metadata.json"] = metadataText;
202
+ const plan = createInstallPlan(options.projectRoot, installFiles);
203
+ await withProjectLock(options.projectRoot, "install", () => {
204
+ if (JSON.stringify(loadConfigDocument(options.projectRoot)) !== options.plannedConfigDocument)
205
+ throw new Error("config changed while preparing package install; retry");
206
+ executeInstallPlan(plan, options.fs);
207
+ });
208
+ return {
209
+ artifactVersion: candidateRoot.root.fullVersion,
210
+ candidateRootSha256,
211
+ packageSetSha256: candidateRoot.root.packageSetSha256,
212
+ catalogSha256,
213
+ metadataSha256: sha256Bytes(metadataText),
214
+ artifactBytes: packagePlan.packages.reduce((sum, row) => sum + row.compressedBytes, 0),
215
+ files: Object.keys(installFiles).sort((a, b) => a.localeCompare(b, "en")),
216
+ packageCount: report.packageCount,
217
+ planItems: plan.items.length,
218
+ selectedFiles: installedFileCount,
219
+ networkBytes: report.networkBytes + report.metadataNetworkBytes,
220
+ cacheHit: report.networkBytes === 0 && report.metadataNetworkBytes === 0,
221
+ cacheHits: report.cacheHits,
222
+ metadataCacheHits: report.metadataCacheHits,
223
+ transferRetryCount: report.transferRetryCount,
224
+ target: options.config.target,
225
+ targetSha256: tree.sha256,
226
+ targetFileCount: tree.fileCount,
227
+ targetByteCount: tree.byteCount,
228
+ fallbacks: packagePlan.fallbacks,
229
+ };
230
+ }
231
+ catch (error) {
232
+ try {
233
+ if (options.fs.existsSync(stageRootPath))
234
+ options.fs.rmSync(stageRootPath, { recursive: true, force: true });
235
+ }
236
+ catch (cleanupError) {
237
+ throw new AggregateError([error, cleanupError], `small package install failed; staging cleanup failed at ${stageRootPath}`);
238
+ }
239
+ throw error;
240
+ }
241
+ }
242
+ function smallPackageTypesReexport(target, outputDir) {
243
+ const from = posix.relative(outputDir.replace(/\\/g, "/"), `.moeicons/artifact/${target}/types`);
244
+ const specifier = from.startsWith(".") ? from : `./${from}`;
245
+ if (target === "react")
246
+ return `export type { ReactIconProps } from "${specifier}";\n`;
247
+ if (target === "vue")
248
+ return `export type { VueIconProps } from "${specifier}";\n`;
249
+ if (target === "vanilla")
250
+ return `export type { VanillaIconOptions } from "${specifier}";\n`;
251
+ return "export {};\n";
252
+ }
@@ -0,0 +1,27 @@
1
+ import { type CandidateRoot, type PackageRow, type PageRef } from './small-pack/candidate-set.cjs';
2
+ export interface CandidateIdentity {
3
+ fullVersion: string;
4
+ tier: 'free' | 'pro';
5
+ sourceCommit: string;
6
+ generatorCommit: string;
7
+ packageSetSha256: string;
8
+ }
9
+ export interface SelectedCandidate {
10
+ readonly root: CandidateRoot;
11
+ readonly rows: readonly PackageRow[];
12
+ }
13
+ export declare function readSelectedCandidate(rootBytes: Uint8Array, expected: CandidateIdentity, readPage: (ref: PageRef) => Promise<Uint8Array>): Promise<SelectedCandidate>;
14
+ export declare function planSelectedPackages(candidate: SelectedCandidate, resourceKeys: readonly string[]): readonly PackageRow[];
15
+ export declare function verifySelectedPackage(row: PackageRow, bytes: Uint8Array): Readonly<Record<string, Uint8Array>>;
16
+ export interface SelectedPackageIo {
17
+ readCache(row: PackageRow): Promise<Uint8Array | undefined>;
18
+ writeCache(row: PackageRow, bytes: Uint8Array): Promise<void>;
19
+ download(row: PackageRow): Promise<Uint8Array>;
20
+ consume(row: PackageRow, files: Readonly<Record<string, Uint8Array>>): Promise<void>;
21
+ signal?: AbortSignal;
22
+ }
23
+ export declare function consumeSelectedPackages(candidate: SelectedCandidate, resourceKeys: readonly string[], io: SelectedPackageIo): Promise<{
24
+ packageCount: number;
25
+ networkBytes: number;
26
+ cacheHits: number;
27
+ }>;
@@ -0,0 +1,114 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ import { canonicalBytes, sha256, parseCandidateSet, validatePackagePages, verifyPackagePayload } from './small-pack/candidate-set.cjs';
3
+ import { readTarEntries } from './small-pack/tar-read.cjs';
4
+ function check(ok, message) { if (!ok)
5
+ throw new Error(`small-package selection: ${message}`); }
6
+ export async function readSelectedCandidate(rootBytes, expected, readPage) {
7
+ const root = parseCandidateSet(rootBytes);
8
+ for (const key of ['fullVersion', 'tier', 'sourceCommit', 'generatorCommit', 'packageSetSha256'])
9
+ check(root[key] === expected[key], `candidate ${key} differs from exact pin`);
10
+ check(root.packagePages.reduce((sum, ref) => sum + ref.sizeBytes, 0) <= 16 * 1024 ** 2, 'metadata exceeds 16 MiB budget');
11
+ const pages = new Map();
12
+ for (const ref of root.packagePages)
13
+ pages.set(ref.filename, await readPage({ ...ref }));
14
+ return { root, rows: validatePackagePages(root, pages) };
15
+ }
16
+ export function planSelectedPackages(candidate, resourceKeys) {
17
+ check(resourceKeys.length > 0 && new Set(resourceKeys).size === resourceKeys.length, 'explicit unique resource keys required');
18
+ const keys = new Map(candidate.rows.map(row => [row.resourceKey, row]));
19
+ const digests = new Map(candidate.rows.map(row => [row.packageSha256, row]));
20
+ check(digests.size === candidate.rows.length, 'ambiguous package digest across directory');
21
+ const visited = new Set(), visiting = new Set(), ordered = [];
22
+ const visit = (row) => {
23
+ check(row.tier === candidate.root.tier && row.fullVersion === candidate.root.fullVersion, 'selected row identity mismatch');
24
+ if (visited.has(row.packageSha256))
25
+ return;
26
+ check(!visiting.has(row.packageSha256), 'cyclic package dependency');
27
+ visiting.add(row.packageSha256);
28
+ for (const digest of row.dependencies) {
29
+ const dependency = digests.get(digest);
30
+ check(dependency, 'missing selected dependency');
31
+ visit(dependency);
32
+ }
33
+ visiting.delete(row.packageSha256);
34
+ visited.add(row.packageSha256);
35
+ ordered.push(JSON.parse(canonicalBytes(row).toString('utf8')));
36
+ };
37
+ for (const key of resourceKeys) {
38
+ const row = keys.get(key);
39
+ check(row, `selected resource absent: ${key}`);
40
+ visit(row);
41
+ }
42
+ return ordered;
43
+ }
44
+ function object(value) { check(value && typeof value === 'object' && !Array.isArray(value), 'manifest object required'); return value; }
45
+ export function verifySelectedPackage(row, bytes) {
46
+ check(['svg', 'code', 'shared', 'metadata', 'bitmap'].includes(row.kind), 'unsupported selected package kind');
47
+ verifyPackagePayload(row, bytes);
48
+ const tar = gunzipSync(bytes, { maxOutputLength: row.expandedBytes + (row.fileCount * 4 + 4) * 512 + 10240 });
49
+ const entries = readTarEntries(tar, { maxEntries: row.fileCount * 4 + 4, maxExpandedBytes: row.expandedBytes + 1024 * 1024 });
50
+ const files = Object.create(null);
51
+ const paths = new Set();
52
+ for (const entry of entries) {
53
+ if (entry.type === 'dir' && entry.path === '.')
54
+ continue;
55
+ const name = entry.path.startsWith('./') ? entry.path.slice(2) : entry.path;
56
+ check(name.length <= 1024 && !/[\\:]/.test(name) && ![...name].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) && !name.startsWith('/') && name.split('/').every(p => p && p !== '.' && p !== '..'), 'unsafe package path');
57
+ check(!paths.has(name), 'duplicate package path');
58
+ paths.add(name);
59
+ check(entry.type === 'file' || entry.type === 'dir', 'links and special files forbidden');
60
+ if (entry.type === 'file') {
61
+ check(entry.data, 'file bytes missing');
62
+ files[name] = entry.data;
63
+ }
64
+ }
65
+ check(Object.keys(files).length === row.fileCount && Object.values(files).reduce((sum, b) => sum + b.length, 0) === row.expandedBytes, 'package inventory differs from row');
66
+ const bytesManifest = files['manifest.json'];
67
+ check(bytesManifest && bytesManifest.length <= 8 * 1024 ** 2, 'bounded package manifest required');
68
+ const manifest = object(JSON.parse(Buffer.from(bytesManifest).toString('utf8')));
69
+ check(Buffer.from(bytesManifest).equals(canonicalBytes(manifest)) && manifest.schemaVersion === 1, 'canonical package manifest required');
70
+ const scope = ['kind', 'tier', 'styleGroupId', 'target', 'variant'];
71
+ check(Object.keys(manifest).every(k => ['schemaVersion', 'files', ...scope].includes(k)), 'unexpected manifest fields');
72
+ for (const key of scope)
73
+ check(canonicalBytes({ value: manifest[key] }).equals(canonicalBytes({ value: row[key] })), 'package scope differs from row');
74
+ check(Array.isArray(manifest.files) && manifest.files.length === row.fileCount - 1, 'manifest inventory mismatch');
75
+ const seen = new Set(['manifest.json']);
76
+ for (const raw of manifest.files) {
77
+ const item = object(raw);
78
+ check(Object.keys(item).sort().join(',') === 'byteSize,path,sha256' && typeof item.path === 'string' && !seen.has(item.path), 'invalid manifest file');
79
+ seen.add(item.path);
80
+ const data = files[item.path];
81
+ check(data && data.length === item.byteSize && sha256(data) === item.sha256, 'manifest file SHA/size mismatch');
82
+ if (row.kind === 'svg')
83
+ check(/^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/.test(item.path), 'SVG package contains another resource kind');
84
+ if (row.kind === 'code' || row.kind === 'shared')
85
+ check(/\.(js|cjs|mjs|ts|cts|mts|tsx|jsx|json|map|vue)$/.test(item.path), 'code package contains non-code payload');
86
+ if (row.kind === 'metadata')
87
+ check(['metadata/MANUAL.md', 'metadata/catalog.json', 'metadata/manifest.json'].includes(item.path), 'unexpected metadata payload');
88
+ if (row.kind === 'bitmap')
89
+ check(new RegExp(`^icons/[a-z0-9]+(?:-[a-z0-9]+)*\\.${row.variant?.format}$`).test(item.path), 'bitmap package crosses tuple scope');
90
+ }
91
+ return files;
92
+ }
93
+ export async function consumeSelectedPackages(candidate, resourceKeys, io) {
94
+ const plan = planSelectedPackages(candidate, resourceKeys);
95
+ let networkBytes = 0, cacheHits = 0;
96
+ for (const row of plan) {
97
+ io.signal?.throwIfAborted();
98
+ let bytes = await io.readCache(row);
99
+ const cached = bytes !== undefined;
100
+ if (bytes !== undefined)
101
+ cacheHits++;
102
+ else {
103
+ bytes = await io.download(row);
104
+ networkBytes += bytes.length;
105
+ }
106
+ const files = verifySelectedPackage(row, bytes);
107
+ // Cache only validated complete packages before consumption, so a later
108
+ // target write failure can reuse this unit without another network fetch.
109
+ if (!cached)
110
+ await io.writeCache(row, bytes);
111
+ await io.consume(row, files);
112
+ }
113
+ return { packageCount: plan.length, networkBytes, cacheHits };
114
+ }
@@ -4,6 +4,7 @@ interface ParsedVersion {
4
4
  minor: number;
5
5
  patch: number;
6
6
  channel: "stable" | "alpha" | "beta";
7
+ prereleaseNumber: number | null;
7
8
  }
8
9
  export declare function parseVersion(value: string): ParsedVersion | undefined;
9
10
  export type UpdateDecision = {
@@ -1,9 +1,15 @@
1
- const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta))?$/;
1
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta)(?:\.(0|[1-9]\d*))?)?$/;
2
2
  export function parseVersion(value) {
3
3
  const match = VERSION.exec(value);
4
4
  if (!match)
5
5
  return undefined;
6
- return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), channel: match[4] ?? "stable" };
6
+ return {
7
+ major: Number(match[1]),
8
+ minor: Number(match[2]),
9
+ patch: Number(match[3]),
10
+ channel: match[4] ?? "stable",
11
+ prereleaseNumber: match[4] ? Number(match[5] ?? 0) : null,
12
+ };
7
13
  }
8
14
  export function decideUpdate(input) {
9
15
  if (input.installedTier !== input.remoteTier)
@@ -14,7 +20,8 @@ export function decideUpdate(input) {
14
20
  return { kind: "refuse", reason: "invalid-version" };
15
21
  if (local.channel !== remote.channel)
16
22
  return { kind: "refuse", reason: "channel-mismatch" };
17
- const comparison = remote.major - local.major || remote.minor - local.minor || remote.patch - local.patch;
23
+ const comparison = remote.major - local.major || remote.minor - local.minor || remote.patch - local.patch ||
24
+ (local.channel === "stable" ? 0 : (remote.prereleaseNumber ?? 0) - (local.prereleaseNumber ?? 0));
18
25
  if (comparison === 0)
19
26
  return { kind: "current" };
20
27
  return comparison > 0 ? { kind: "update" } : { kind: "refuse", reason: "downgrade" };
@@ -84,7 +84,7 @@ export function createProjectMcpServices(runtime, execute) {
84
84
  for (const key of ["dryRun"])
85
85
  if (args[key] !== undefined && typeof args[key] !== "boolean")
86
86
  throw new McpArgumentError(`${key} must be boolean`);
87
- if (args.target !== undefined && !["react", "vue", "vanilla", "assets"].includes(String(args.target)))
87
+ if (args.target !== undefined && (typeof args.target !== "string" || !["react", "vue", "vanilla", "assets"].includes(args.target)))
88
88
  throw new McpArgumentError("invalid target");
89
89
  if (name === "install_icon_group") {
90
90
  if (typeof args.groupId !== "string" || typeof args.projectPath !== "string")
@@ -106,7 +106,7 @@ function requireCommonConfigFields(obj, sourceCatalog, lenientCatalog) {
106
106
  throw new Error("themes must be an object");
107
107
  }
108
108
  if (obj.downloadMode !== undefined &&
109
- !["auto", "icons", "full"].includes(String(obj.downloadMode)))
109
+ (typeof obj.downloadMode !== "string" || !["auto", "icons", "full"].includes(obj.downloadMode)))
110
110
  throw new Error('downloadMode must be "auto", "icons" or "full"');
111
111
  if (obj.missingIconPolicy !== undefined &&
112
112
  obj.missingIconPolicy !== "fallback" &&
@@ -1,4 +1,5 @@
1
1
  import { type BitmapShard } from "../core/bitmap-shards.js";
2
+ import { type PackageInstallMetadata } from "./package-install-metadata.js";
2
3
  export interface InstallMetadata {
3
4
  readonly schemaVersion: 1;
4
5
  readonly delivery?: {
@@ -36,7 +37,7 @@ export interface InstallMetadataParseOptions {
36
37
  }
37
38
  export type InstalledResourceState = {
38
39
  readonly kind: "ok";
39
- readonly metadata: InstallMetadata;
40
+ readonly metadata: InstallMetadata | PackageInstallMetadata;
40
41
  } | {
41
42
  readonly kind: "missing";
42
43
  readonly message: string;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { bitmapShardSetSha256, parseBitmapShard } from "../core/bitmap-shards.js";
5
+ import { parsePackageInstallMetadata } from "./package-install-metadata.js";
5
6
  const SHA256 = /^[a-f0-9]{64}$/;
6
7
  const VERSION = /^\d+\.\d+\.\d+(?:-(?:alpha|beta))?$/;
7
8
  const LOCAL_TEST_VERSION = /^\d+\.\d+\.\d+-test$/;
@@ -133,16 +134,18 @@ export function readInstalledResourceState(projectRoot, expectedTier, opts = {})
133
134
  kind: "missing",
134
135
  message: "managed install metadata is missing; run 'moeicons install' to repair or reinstall",
135
136
  };
136
- const metadata = parseInstallMetadata(readFileSync(metadataPath, "utf8"), opts);
137
+ const raw = readFileSync(metadataPath, "utf8");
138
+ const metadata = parseInstallMetadata(raw, opts) ?? parsePackageInstallMetadata(raw);
137
139
  if (!metadata)
138
140
  return {
139
141
  kind: "invalid",
140
142
  message: "managed install metadata is invalid; run 'moeicons install' to repair or reinstall",
141
143
  };
142
- if (expectedTier && metadata.tier !== expectedTier)
144
+ const tier = metadata.schemaVersion === 1 ? metadata.tier : metadata.candidate.tier;
145
+ if (expectedTier && tier !== expectedTier)
143
146
  return {
144
147
  kind: "invalid",
145
- message: `installed tier ${metadata.tier} does not match config tier ${expectedTier}`,
148
+ message: `installed tier ${tier} does not match config tier ${expectedTier}`,
146
149
  };
147
150
  for (const [relative, expectedHash] of Object.entries(metadata.managedFiles)) {
148
151
  const path = join(projectRoot, relative);
@@ -11,7 +11,16 @@ export interface InstallPlanItem {
11
11
  readonly rel?: string;
12
12
  readonly content?: string;
13
13
  readonly bytes?: Uint8Array;
14
+ /** A verified file in a private .moeicons/.package-stage-<uuid> directory. */
15
+ readonly staged?: StagedInstallFile;
14
16
  }
17
+ export interface StagedInstallFile {
18
+ readonly kind: "staged-file";
19
+ /** Project-relative path under .moeicons/.package-stage-<uuid>. */
20
+ readonly path: string;
21
+ readonly sha256: string;
22
+ }
23
+ export type ManagedFileContent = string | Uint8Array | StagedInstallFile;
15
24
  export interface InstallPlan {
16
25
  readonly root?: string;
17
26
  readonly items: readonly InstallPlanItem[];
@@ -37,7 +46,7 @@ export declare function safeManagedPath(projectRoot: string, relative: string):
37
46
  * Atomically reconcile an explicit cross-directory managed set. `removePaths`
38
47
  * must come from trusted metadata; this function never scans or uses globs.
39
48
  */
40
- export declare function executeManagedReconcile(projectRoot: string, writes: Readonly<Record<string, string | Uint8Array>>, removePaths: readonly string[], fs_: TransactionalFsWithCopy, options?: {
49
+ export declare function executeManagedReconcile(projectRoot: string, writes: Readonly<Record<string, ManagedFileContent>>, removePaths: readonly string[], fs_: TransactionalFsWithCopy, options?: {
41
50
  readonly expectedText?: Readonly<Record<string, string | undefined>>;
42
51
  readonly expectedSha256?: Readonly<Record<string, string | undefined>>;
43
52
  }): void;
@@ -47,7 +56,7 @@ export declare function executeManagedReconcile(projectRoot: string, writes: Rea
47
56
  */
48
57
  export declare function recoverManagedReconcile(projectRoot: string, fs_: TransactionalFsWithCopy): number;
49
58
  /** Pure list of install operations with expected checksums. */
50
- export declare function createInstallPlan(targetRoot: string, files: Readonly<Record<string, string | Uint8Array>>): InstallPlan;
59
+ export declare function createInstallPlan(targetRoot: string, files: Readonly<Record<string, ManagedFileContent>>): InstallPlan;
51
60
  /** Create a deterministic ZIP for delivery (fixed mtime). */
52
61
  export declare function createArtifactZip(files: Readonly<Record<string, string>>): Uint8Array;
53
62
  /** Unzip an artifact, rejecting unsafe paths. */