@moewolf/moe-icons-cli 0.0.1 → 0.0.2

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 (151) hide show
  1. package/README.md +243 -9
  2. package/bin/check-node.cjs +36 -0
  3. package/bin/moeicons.js +12 -1
  4. package/dist/api/client.d.ts +13 -2
  5. package/dist/api/client.js +145 -21
  6. package/dist/auth/device-login.d.ts +10 -7
  7. package/dist/auth/device-login.js +60 -37
  8. package/dist/auth/login-schemas.d.ts +24 -0
  9. package/dist/auth/login-schemas.js +67 -0
  10. package/dist/catalog/catalog.d.ts +11 -0
  11. package/dist/catalog/catalog.js +43 -0
  12. package/dist/catalog/catalog.json +538 -3395
  13. package/dist/cli.d.ts +11 -2
  14. package/dist/cli.js +773 -147
  15. package/dist/commands/parser.d.ts +13 -2
  16. package/dist/commands/parser.js +138 -50
  17. package/dist/config-package/generated/SOURCE.json +25 -0
  18. package/dist/config-package/generated/moeicons-config.schema.json +191 -0
  19. package/dist/config-package/generated/moeicons.config.jsonc +42 -0
  20. package/dist/config-package/generated/render-config.cjs +145 -0
  21. package/dist/config-package/generated/validate-config.cjs +293 -0
  22. package/dist/config-package/generated-config.d.ts +32 -0
  23. package/dist/config-package/generated-config.js +19 -0
  24. package/dist/core/auth.d.ts +24 -0
  25. package/dist/core/auth.js +127 -8
  26. package/dist/core/bitmap-assets.d.ts +19 -5
  27. package/dist/core/bitmap-assets.js +40 -31
  28. package/dist/core/bitmap-shard-budget.d.ts +43 -0
  29. package/dist/core/bitmap-shard-budget.js +16 -0
  30. package/dist/core/bitmap-shard-cache.d.ts +68 -0
  31. package/dist/core/bitmap-shard-cache.js +115 -0
  32. package/dist/core/bitmap-shard-download.d.ts +130 -0
  33. package/dist/core/bitmap-shard-download.js +359 -0
  34. package/dist/core/bitmap-shard-resolver.d.ts +129 -0
  35. package/dist/core/bitmap-shard-resolver.js +262 -0
  36. package/dist/core/bitmap-shards.d.ts +70 -0
  37. package/dist/core/bitmap-shards.js +249 -0
  38. package/dist/core/bootstrap.d.ts +37 -0
  39. package/dist/core/bootstrap.js +54 -0
  40. package/dist/core/cache.d.ts +34 -0
  41. package/dist/core/cache.js +96 -0
  42. package/dist/core/context.d.ts +26 -0
  43. package/dist/core/doctor.d.ts +48 -0
  44. package/dist/core/doctor.js +190 -0
  45. package/dist/core/free-download.d.ts +64 -6
  46. package/dist/core/free-download.js +402 -30
  47. package/dist/core/generate.d.ts +29 -0
  48. package/dist/core/generate.js +341 -65
  49. package/dist/core/icon-selection.d.ts +8 -0
  50. package/dist/core/icon-selection.js +19 -0
  51. package/dist/core/install.d.ts +14 -0
  52. package/dist/core/install.js +132 -13
  53. package/dist/core/library-update.d.ts +5 -1
  54. package/dist/core/library-update.js +176 -17
  55. package/dist/core/local-test-env.d.ts +24 -0
  56. package/dist/core/local-test-env.js +43 -0
  57. package/dist/core/metadata-sync.d.ts +30 -0
  58. package/dist/core/metadata-sync.js +97 -0
  59. package/dist/core/pro-download.d.ts +63 -0
  60. package/dist/core/pro-download.js +247 -27
  61. package/dist/core/pro-install.d.ts +11 -3
  62. package/dist/core/pro-install.js +181 -19
  63. package/dist/core/pro-resources.d.ts +79 -0
  64. package/dist/core/pro-resources.js +204 -0
  65. package/dist/core/pro-selected-download.d.ts +18 -0
  66. package/dist/core/pro-selected-download.js +269 -0
  67. package/dist/core/release-descriptor.d.ts +42 -1
  68. package/dist/core/release-descriptor.js +117 -9
  69. package/dist/core/resource-variant.d.ts +27 -10
  70. package/dist/core/resource-variant.js +75 -34
  71. package/dist/core/selected-resources.d.ts +59 -0
  72. package/dist/core/selected-resources.js +350 -0
  73. package/dist/core/signed-artifact.d.ts +8 -1
  74. package/dist/core/signed-artifact.js +35 -11
  75. package/dist/core/target-subtree.d.ts +8 -0
  76. package/dist/core/target-subtree.js +54 -3
  77. package/dist/core/version-service.d.ts +11 -0
  78. package/dist/core/version-service.js +129 -24
  79. package/dist/core/wizard.d.ts +20 -17
  80. package/dist/core/wizard.js +66 -28
  81. package/dist/errors/errors.d.ts +2 -1
  82. package/dist/errors/errors.js +1 -0
  83. package/dist/generator/generate.d.ts +1 -1
  84. package/dist/generator/generate.js +420 -213
  85. package/dist/generator/shared/SOURCE.json +7 -0
  86. package/dist/generator/shared/svg-model.cjs +156 -0
  87. package/dist/generator/svg-model.d.ts +16 -0
  88. package/dist/generator/svg-model.js +4 -0
  89. package/dist/generator/theme-resolve.d.ts +2 -0
  90. package/dist/generator/theme-resolve.js +57 -26
  91. package/dist/mcp/server.d.ts +23 -36
  92. package/dist/mcp/server.js +74 -152
  93. package/dist/mcp/services.d.ts +4 -0
  94. package/dist/mcp/services.js +230 -0
  95. package/dist/metadata/archive.d.ts +38 -0
  96. package/dist/metadata/archive.js +94 -0
  97. package/dist/metadata/manifest.d.ts +33 -0
  98. package/dist/metadata/manifest.js +136 -0
  99. package/dist/metadata/version.d.ts +22 -0
  100. package/dist/metadata/version.js +53 -0
  101. package/dist/project/anchors/application.d.ts +13 -0
  102. package/dist/project/anchors/application.js +377 -0
  103. package/dist/project/anchors/apply.d.ts +28 -0
  104. package/dist/project/anchors/apply.js +65 -0
  105. package/dist/project/anchors/diagnose.d.ts +26 -0
  106. package/dist/project/anchors/diagnose.js +90 -0
  107. package/dist/project/anchors/helpers.d.ts +20 -0
  108. package/dist/project/anchors/helpers.js +55 -0
  109. package/dist/project/anchors/moeicons-config.d.ts +21 -0
  110. package/dist/project/anchors/moeicons-config.js +83 -0
  111. package/dist/project/anchors/parse.d.ts +14 -0
  112. package/dist/project/anchors/parse.js +36 -0
  113. package/dist/project/anchors/project-manifest.d.ts +19 -0
  114. package/dist/project/anchors/project-manifest.js +109 -0
  115. package/dist/project/anchors/style.d.ts +12 -0
  116. package/dist/project/anchors/style.js +87 -0
  117. package/dist/project/anchors/types.d.ts +32 -0
  118. package/dist/project/anchors/types.js +9 -0
  119. package/dist/project/config.d.ts +59 -5
  120. package/dist/project/config.js +234 -102
  121. package/dist/project/install-metadata.d.ts +23 -2
  122. package/dist/project/install-metadata.js +58 -7
  123. package/dist/project/install.d.ts +24 -4
  124. package/dist/project/install.js +402 -190
  125. package/dist/project/project-lock.d.ts +2 -2
  126. package/dist/project/project-lock.js +4 -4
  127. package/dist/project/tailwind.d.ts +2 -2
  128. package/dist/project/tailwind.js +3 -4
  129. package/dist/project/tar-gz.d.ts +18 -1
  130. package/dist/project/tar-gz.js +40 -3
  131. package/dist/tui/components.d.ts +62 -0
  132. package/dist/tui/components.js +100 -0
  133. package/dist/tui/primitives.d.ts +1 -0
  134. package/dist/tui/primitives.js +8 -1
  135. package/dist/ui/banner.d.ts +31 -3
  136. package/dist/ui/banner.js +120 -25
  137. package/dist/ui/branded-prompts.d.ts +41 -0
  138. package/dist/ui/branded-prompts.js +80 -0
  139. package/dist/ui/clack.d.ts +6 -3
  140. package/dist/ui/clack.js +56 -5
  141. package/dist/ui/create-ui.d.ts +1 -0
  142. package/dist/ui/create-ui.js +5 -1
  143. package/dist/ui/generated/wordmark-large.d.ts +17 -0
  144. package/dist/ui/generated/wordmark-large.js +28 -0
  145. package/dist/ui/generated/wordmark.d.ts +2 -0
  146. package/dist/ui/generated/wordmark.js +2 -0
  147. package/dist/ui/non-interactive.js +8 -0
  148. package/dist/ui/stream.js +30 -0
  149. package/dist/ui/theme.d.ts +42 -0
  150. package/dist/ui/theme.js +59 -0
  151. package/package.json +14 -6
@@ -0,0 +1,269 @@
1
+ import { sha256Bytes } from "../project/install-metadata.js";
2
+ import { gunzipSync } from "node:zlib";
3
+ import { join } from "node:path";
4
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync, readdirSync, } from "node:fs";
5
+ import { CliError } from "../errors/index.js";
6
+ import { runAccessTokenUseCase } from "./auth.js";
7
+ import { downloadSignedArtifact, parseSignedDescriptor } from "./signed-artifact.js";
8
+ import { parseResourceRefs, parseResourceIndex, planSelectedResources, downloadSelectedResources, } from "./selected-resources.js";
9
+ import { resolveProDescriptorEndpoint, proSignedDownloadOptions, extractProMetadata, fetchProDescriptor, } from "./pro-download.js";
10
+ import { validateConfigDocument, } from "../project/config.js";
11
+ import { parseCatalog } from "../catalog/catalog.js";
12
+ import { cacheArtifact } from "./cache.js";
13
+ import { homedir } from "node:os";
14
+ export async function downloadProSelected(context, auth, descriptor, selection, deps) {
15
+ const fetchFn = deps.fetch ?? fetch;
16
+ const endpointInfo = resolveProDescriptorEndpoint(context.env);
17
+ const endpoint = new URL(endpointInfo.url.replace(/\/artifact-descriptor$/, "/resource-descriptor"));
18
+ if (endpoint.origin !== "https://api.moeicons.com" && !endpointInfo.allowLoopback)
19
+ throw new CliError("VALIDATION_ERROR", "resource descriptor endpoint must be the trusted API or a test loopback");
20
+ const getDescriptor = async (kind) => {
21
+ let token = await runAccessTokenUseCase(context, auth);
22
+ let refreshed = false;
23
+ for (let attempt = 0; attempt < 3; attempt++) {
24
+ let response;
25
+ try {
26
+ response = await fetchFn(endpoint, {
27
+ method: "POST",
28
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
29
+ body: JSON.stringify({
30
+ version: descriptor.version,
31
+ descriptorSha256: descriptor.descriptorSha256,
32
+ kind,
33
+ }),
34
+ redirect: "error",
35
+ signal: AbortSignal.any([context.signal, AbortSignal.timeout(30000)]),
36
+ });
37
+ }
38
+ catch {
39
+ if (context.signal.aborted)
40
+ throw new CliError("CANCELLED", "selected resource download cancelled");
41
+ if (attempt === 2)
42
+ throw new CliError("NETWORK_ERROR", "resource authorization request failed after 3 attempts; retry the same fixed release");
43
+ await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
44
+ continue;
45
+ }
46
+ if (response.status === 401 && !refreshed) {
47
+ await response.body?.cancel();
48
+ token = await runAccessTokenUseCase(context, auth, true);
49
+ refreshed = true;
50
+ continue;
51
+ }
52
+ if (response.status === 403) {
53
+ await response.body?.cancel();
54
+ throw new CliError("FORBIDDEN", "active pro entitlement required for selected resources");
55
+ }
56
+ // Bound JSON reads independently of Content-Length.
57
+ const reader = response.body?.getReader();
58
+ const chunks = [];
59
+ let size = 0;
60
+ if (!reader)
61
+ throw new CliError("NETWORK_ERROR", "empty resource descriptor response");
62
+ try {
63
+ for (;;) {
64
+ const part = await reader.read();
65
+ if (part.done)
66
+ break;
67
+ size += part.value.length;
68
+ if (size > 8192) {
69
+ await reader.cancel();
70
+ throw new CliError("VALIDATION_ERROR", "resource descriptor exceeds 8 KiB");
71
+ }
72
+ chunks.push(part.value);
73
+ }
74
+ }
75
+ finally {
76
+ reader.releaseLock();
77
+ }
78
+ let value;
79
+ try {
80
+ const parsed = JSON.parse(Buffer.concat(chunks).toString());
81
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
82
+ throw new Error("invalid JSON object");
83
+ value = parsed;
84
+ }
85
+ catch {
86
+ throw new CliError("VALIDATION_ERROR", "invalid resource descriptor JSON");
87
+ }
88
+ if (response.status === 404 && value?.code === "RESOURCE_NOT_AVAILABLE")
89
+ return undefined;
90
+ if (response.status === 401)
91
+ throw new CliError("AUTH_ERROR", "resource authentication failed after refresh; run moeicons login");
92
+ if ([429, 500, 502, 503, 504].includes(response.status) && attempt < 2) {
93
+ await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
94
+ continue;
95
+ }
96
+ if (response.status !== 200)
97
+ throw new CliError("NETWORK_ERROR", `resource descriptor request failed (${response.status}); no full archive was downloaded`);
98
+ const filename = kind === "resource-index"
99
+ ? `moe-icons-pro-resource-index-${descriptor.version}.json.gz`
100
+ : `moe-icons-pro-resources-${descriptor.version}.bin`;
101
+ if (!value ||
102
+ Array.isArray(value) ||
103
+ value.ok !== true ||
104
+ value.tier !== "pro" ||
105
+ value.kind !== kind ||
106
+ value.version !== descriptor.version ||
107
+ value.descriptorSha256 !== descriptor.descriptorSha256 ||
108
+ value.parentArtifactSha256 !== descriptor.sha256 ||
109
+ value.filename !== filename ||
110
+ (kind === "resource-index" &&
111
+ (value.sha256 !== descriptor.resources.index.sha256 ||
112
+ value.size !== descriptor.resources.index.size)) ||
113
+ Object.keys(value).some((k) => ![
114
+ "ok",
115
+ "tier",
116
+ "kind",
117
+ "version",
118
+ "descriptorSha256",
119
+ "filename",
120
+ "parentArtifactSha256",
121
+ "url",
122
+ "expiresAt",
123
+ "size",
124
+ "sha256",
125
+ ].includes(k)))
126
+ throw new CliError("VALIDATION_ERROR", "resource descriptor identity changed; retry the original fixed release");
127
+ const signed = parseSignedDescriptor({ url: value.url, expiresAt: value.expiresAt, size: value.size, sha256: value.sha256 }, context.now().getTime(), { allowLoopback: endpointInfo.allowLoopback });
128
+ return { ...signed, filename, parentArtifactSha256: descriptor.sha256 };
129
+ }
130
+ throw new CliError("NETWORK_ERROR", "resource descriptor retry budget exhausted");
131
+ };
132
+ const indexDescriptor = await getDescriptor("resource-index");
133
+ if (!indexDescriptor)
134
+ throw new CliError("VALIDATION_ERROR", "release advertises selected resources but the resource endpoint is unavailable; retry the same release or explicitly set downloadMode=full. No full archive was downloaded");
135
+ if (indexDescriptor.size > 8 * 1024 * 1024)
136
+ throw new CliError("VALIDATION_ERROR", "compressed resource index exceeds 8 MiB");
137
+ if (!descriptor.metadata)
138
+ throw new CliError("VALIDATION_ERROR", "pro metadata is missing");
139
+ const downloadOptions = proSignedDownloadOptions(context, deps);
140
+ const expired = (error) => error instanceof CliError &&
141
+ /HTTP (?:401|403)\b|failed with (?:401|403)\b|expired/i.test(error.message);
142
+ const originalMetadata = descriptor.metadata;
143
+ let currentMetadata = originalMetadata;
144
+ let metadataBytes;
145
+ for (let attempt = 0; attempt < 3; attempt++)
146
+ try {
147
+ metadataBytes = await downloadSignedArtifact(currentMetadata, downloadOptions);
148
+ break;
149
+ }
150
+ catch (error) {
151
+ if (!expired(error) || attempt === 2)
152
+ throw error;
153
+ const renewed = await fetchProDescriptor(context, auth, { version: descriptor.version, descriptorSha256: descriptor.descriptorSha256 }, deps);
154
+ if (renewed.sha256 !== descriptor.sha256 ||
155
+ !renewed.metadata ||
156
+ renewed.metadata.sha256 !== originalMetadata.sha256 ||
157
+ renewed.metadata.size !== originalMetadata.size ||
158
+ JSON.stringify(renewed.resources) !== JSON.stringify(descriptor.resources))
159
+ throw new CliError("VALIDATION_ERROR", "release identity changed during metadata URL renewal");
160
+ currentMetadata = renewed.metadata;
161
+ }
162
+ if (!metadataBytes)
163
+ throw new CliError("NETWORK_ERROR", "metadata URL renewal failed");
164
+ const metadata = extractProMetadata(metadataBytes, descriptor.catalogSha256, descriptor.version);
165
+ const catalog = parseCatalog(JSON.parse(metadata.catalogJson));
166
+ const strict = validateConfigDocument(selection.document, catalog);
167
+ if (strict.kind !== "ok")
168
+ throw new CliError("VALIDATION_ERROR", strict.kind === "invalid" ? strict.message : `config state: ${strict.kind}`);
169
+ const cacheDir = context.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache");
170
+ const io = {
171
+ mkdirSync: (path) => {
172
+ mkdirSync(path, { recursive: true });
173
+ },
174
+ writeFileSync,
175
+ readFileSync,
176
+ existsSync,
177
+ renameSync,
178
+ rmSync,
179
+ readdirSync,
180
+ };
181
+ const cached = join(cacheDir, "resources", "pro", descriptor.version, indexDescriptor.sha256, "index.json.gz");
182
+ let indexBytes = existsSync(cached) ? readFileSync(cached) : undefined;
183
+ if (!indexBytes ||
184
+ indexBytes.length !== indexDescriptor.size ||
185
+ sha256Bytes(indexBytes) !== indexDescriptor.sha256) {
186
+ let signed = indexDescriptor;
187
+ for (let attempt = 0; attempt < 3; attempt++)
188
+ try {
189
+ indexBytes = Buffer.from(await downloadSignedArtifact({
190
+ url: signed.url,
191
+ expiresAt: signed.expiresAt,
192
+ size: signed.size,
193
+ sha256: signed.sha256,
194
+ }, downloadOptions));
195
+ break;
196
+ }
197
+ catch (error) {
198
+ if (!expired(error) || attempt === 2)
199
+ throw error;
200
+ const renewed = await getDescriptor("resource-index");
201
+ if (!renewed ||
202
+ renewed.sha256 !== indexDescriptor.sha256 ||
203
+ renewed.size !== indexDescriptor.size)
204
+ throw new CliError("VALIDATION_ERROR", "resource index changed during URL renewal");
205
+ signed = renewed;
206
+ }
207
+ }
208
+ if (!indexBytes)
209
+ throw new CliError("NETWORK_ERROR", "resource index URL renewal failed");
210
+ let bundle;
211
+ try {
212
+ const parsed = JSON.parse(gunzipSync(indexBytes, { maxOutputLength: 64 * 1024 * 1024 }).toString());
213
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
214
+ throw new Error("invalid index object");
215
+ bundle = parsed.bundle;
216
+ }
217
+ catch {
218
+ throw new CliError("VALIDATION_ERROR", "invalid compressed resource index");
219
+ }
220
+ const refs = parseResourceRefs({
221
+ schemaVersion: 1,
222
+ index: {
223
+ filename: indexDescriptor.filename,
224
+ size: indexDescriptor.size,
225
+ sha256: indexDescriptor.sha256,
226
+ },
227
+ bundle,
228
+ });
229
+ if (refs.bundle.sha256 !== descriptor.resources.bundle.sha256 ||
230
+ refs.bundle.size !== descriptor.resources.bundle.size ||
231
+ refs.bundle.filename !== descriptor.resources.bundle.filename)
232
+ throw new CliError("VALIDATION_ERROR", "resource index bundle does not match advertised release");
233
+ const index = parseResourceIndex(indexBytes, refs, {
234
+ version: descriptor.version,
235
+ tier: "pro",
236
+ artifactSha256: descriptor.sha256,
237
+ });
238
+ const plan = planSelectedResources(strict.config, catalog, index);
239
+ context.ui.note(`Download plan: pro/${strict.config.target}, ${strict.config.icons.length} registered icons, ${Object.keys(strict.config.themes).length} themes, ${plan.paths.length} resource files, up to ${plan.payloadBytes} compressed bytes (verified cache reduces network traffic).${plan.fallbacks.length ? ` Fallbacks: ${plan.fallbacks.join("; ")}` : ""}`, context.signal);
240
+ cacheArtifact(io, cached, indexBytes, refs.index.sha256);
241
+ const downloaded = await downloadSelectedResources(index, refs.index.sha256, plan.paths, {
242
+ io,
243
+ cacheDir,
244
+ fetch: fetchFn,
245
+ signal: context.signal,
246
+ allowedHosts: downloadOptions.allowedHosts,
247
+ ...(endpointInfo.allowLoopback ? { allowLoopback: true } : {}),
248
+ getBundleUrl: async () => {
249
+ const signed = await getDescriptor("resource-bundle");
250
+ if (!signed ||
251
+ signed.sha256 !== refs.bundle.sha256 ||
252
+ signed.size !== refs.bundle.size ||
253
+ signed.filename !== refs.bundle.filename)
254
+ throw new CliError("VALIDATION_ERROR", "resource bundle changed after index verification");
255
+ return signed.url;
256
+ },
257
+ });
258
+ return {
259
+ ...metadata,
260
+ metadataBytes,
261
+ selected: {
262
+ ...downloaded,
263
+ refs,
264
+ indexBytes,
265
+ payloadBytes: plan.payloadBytes,
266
+ fallbacks: plan.fallbacks,
267
+ },
268
+ };
269
+ }
@@ -1,3 +1,4 @@
1
+ import { type ResourceRefs } from "./selected-resources.js";
1
2
  export type ReleaseTarget = "react" | "vue" | "vanilla" | "assets";
2
3
  export declare const RELEASE_TARGETS: readonly ReleaseTarget[];
3
4
  /** Per-target subtree metadata written by the code-library release packer. */
@@ -7,13 +8,40 @@ export interface ReleaseTargetMetadata {
7
8
  readonly fileCount: number;
8
9
  readonly byteCount: number;
9
10
  }
11
+ /** Per-file digest inside a metadata archive. */
12
+ export interface ReleaseFileDigest {
13
+ readonly size: number;
14
+ readonly sha256: string;
15
+ }
16
+ /** Reference to a tier's metadata archive (MANUAL.md / catalog.json / manifest.json). */
17
+ export interface ReleaseMetadataRef {
18
+ readonly filename: string;
19
+ readonly sha256: string;
20
+ readonly size: number;
21
+ readonly files: {
22
+ readonly "MANUAL.md": ReleaseFileDigest;
23
+ readonly "catalog.json": ReleaseFileDigest;
24
+ readonly "manifest.json": ReleaseFileDigest;
25
+ };
26
+ }
27
+ /** Optional assets-only archive reference (ICON-E2E-0907 / E2E-C7). */
28
+ export interface ReleaseAssetsRef {
29
+ readonly filename: string;
30
+ readonly sha256: string;
31
+ readonly size: number;
32
+ }
10
33
  export interface ReleaseTierArtifact {
34
+ readonly resources?: ResourceRefs;
11
35
  readonly filename: string;
12
36
  readonly sha256: string;
37
+ readonly size?: number;
13
38
  readonly styleGroups?: readonly string[];
14
39
  readonly styleGroupCount?: number;
15
40
  readonly targets?: readonly ReleaseTarget[];
16
41
  readonly targetMetadata?: Readonly<Partial<Record<ReleaseTarget, ReleaseTargetMetadata>>>;
42
+ readonly metadata?: ReleaseMetadataRef;
43
+ /** Present on new descriptors; absent on legacy four-archive descriptors. */
44
+ readonly assets?: ReleaseAssetsRef;
17
45
  }
18
46
  export interface ReleaseCatalogRef {
19
47
  readonly filename: string;
@@ -26,15 +54,28 @@ export interface ReleaseDescriptor {
26
54
  readonly fullVersion: string;
27
55
  readonly free: ReleaseTierArtifact;
28
56
  readonly catalog: ReleaseCatalogRef;
57
+ /** ICON-E2E-0907 / E2E-E2: present only on local-test candidates. */
58
+ readonly channel?: "local-test";
59
+ readonly publishable?: boolean;
60
+ readonly schemaVersion?: number;
61
+ readonly baseVersion?: string;
29
62
  }
30
63
  /** Parse release-descriptor.json. Never invent free.filename. */
31
64
  export declare function parseReleaseDescriptor(bytes: Uint8Array): ReleaseDescriptor;
65
+ /** True when a fullVersion is a `X.Y.Z-test` local candidate version. */
66
+ export declare function isLocalTestVersion(fullVersion: string): boolean;
67
+ /**
68
+ * Reject local-test candidate descriptors unless they are explicitly local and
69
+ * non-publishable: an HTTP/remote source, a missing `local-test` channel, or
70
+ * `publishable: true` must never be accepted by the free install flow.
71
+ */
72
+ export declare function assertLocalCandidateAllowed(descriptor: ReleaseDescriptor, viaLocalFixture: boolean): void;
32
73
  /** Parse `hex filename` sidecar produced next to the descriptor. */
33
74
  export declare function parseSha256Sidecar(text: string): string;
34
75
  export declare const PUBLIC_FREE_REPO: {
35
76
  readonly owner: "moewolf-dev";
36
77
  readonly name: "moe-icons";
37
78
  };
38
- export declare const FREE_DOWNLOAD_HOSTS: readonly ["github.com", "objects.githubusercontent.com"];
79
+ export declare const FREE_DOWNLOAD_HOSTS: readonly ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"];
39
80
  export declare function githubReleaseAssetUrl(tag: string, filename: string): string;
40
81
  export declare function cacheKey(fullVersion: string, sha256: string): string;
@@ -1,7 +1,9 @@
1
+ import { parseResourceRefs } from "./selected-resources.js";
1
2
  const SHA256_HEX = /^[0-9a-f]{64}$/i;
2
3
  const SAFE_FILENAME = /^[A-Za-z0-9._-]+$/;
3
4
  const SAFE_SUBTREE_PATH = /^[A-Za-z0-9._/-]+$/;
4
5
  export const RELEASE_TARGETS = ["react", "vue", "vanilla", "assets"];
6
+ const LOCAL_TEST_VERSION = /^(\d+)\.(\d+)\.(\d+)-test$/;
5
7
  function isRecord(value) {
6
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
7
9
  }
@@ -34,10 +36,14 @@ function parseTargetMetadata(value, field) {
34
36
  if (typeof entry.path !== "string" || !SAFE_SUBTREE_PATH.test(entry.path)) {
35
37
  throw new Error(`${field}.${key}.path must be a safe subtree path`);
36
38
  }
37
- if (typeof entry.fileCount !== "number" || !Number.isSafeInteger(entry.fileCount) || entry.fileCount < 0) {
39
+ if (typeof entry.fileCount !== "number" ||
40
+ !Number.isSafeInteger(entry.fileCount) ||
41
+ entry.fileCount < 0) {
38
42
  throw new Error(`${field}.${key}.fileCount must be a non-negative integer`);
39
43
  }
40
- if (typeof entry.byteCount !== "number" || !Number.isSafeInteger(entry.byteCount) || entry.byteCount < 0) {
44
+ if (typeof entry.byteCount !== "number" ||
45
+ !Number.isSafeInteger(entry.byteCount) ||
46
+ entry.byteCount < 0) {
41
47
  throw new Error(`${field}.${key}.byteCount must be a non-negative integer`);
42
48
  }
43
49
  result[target] = {
@@ -49,22 +55,80 @@ function parseTargetMetadata(value, field) {
49
55
  }
50
56
  return result;
51
57
  }
52
- function parseTier(value, field) {
58
+ function parseFileDigest(value, field) {
53
59
  if (!isRecord(value))
54
60
  throw new Error(`${field} must be an object`);
61
+ if (typeof value.size !== "number" || !Number.isSafeInteger(value.size) || value.size < 1) {
62
+ throw new Error(`${field}.size must be a positive integer`);
63
+ }
64
+ return { size: value.size, sha256: requireSha(value.sha256, `${field}.sha256`) };
65
+ }
66
+ function parseMetadataRef(value, field) {
67
+ if (!isRecord(value))
68
+ throw new Error(`${field} must be an object`);
69
+ if (!isRecord(value.files))
70
+ throw new Error(`${field}.files must be an object`);
71
+ const files = {
72
+ "MANUAL.md": parseFileDigest(value.files["MANUAL.md"], `${field}.files.MANUAL.md`),
73
+ "catalog.json": parseFileDigest(value.files["catalog.json"], `${field}.files.catalog.json`),
74
+ "manifest.json": parseFileDigest(value.files["manifest.json"], `${field}.files.manifest.json`),
75
+ };
76
+ if (typeof value.size !== "number" || !Number.isSafeInteger(value.size) || value.size < 1) {
77
+ throw new Error(`${field}.size must be a positive integer`);
78
+ }
79
+ return {
80
+ filename: requireFilename(value.filename, `${field}.filename`),
81
+ sha256: requireSha(value.sha256, `${field}.sha256`),
82
+ size: value.size,
83
+ files,
84
+ };
85
+ }
86
+ function parseAssetsRef(value, field) {
87
+ if (!isRecord(value))
88
+ throw new Error(`${field} must be an object`);
89
+ if (typeof value.size !== "number" || !Number.isSafeInteger(value.size) || value.size < 1) {
90
+ throw new Error(`${field}.size must be a positive integer`);
91
+ }
92
+ return {
93
+ filename: requireFilename(value.filename, `${field}.filename`),
94
+ sha256: requireSha(value.sha256, `${field}.sha256`),
95
+ size: value.size,
96
+ };
97
+ }
98
+ function parseTier(value, field, version) {
99
+ if (!isRecord(value))
100
+ throw new Error(`${field} must be an object`);
101
+ const resources = value.resources !== undefined ? parseResourceRefs(value.resources) : undefined;
102
+ if (resources &&
103
+ (resources.index.filename !== `moe-icons-${field}-resource-index-${version}.json.gz` ||
104
+ resources.bundle.filename !== `moe-icons-${field}-resources-${version}.bin`))
105
+ throw new Error(`${field}.resources filenames do not match tier/version`);
55
106
  return {
107
+ ...(resources ? { resources } : {}),
56
108
  filename: requireFilename(value.filename, `${field}.filename`),
57
109
  sha256: requireSha(value.sha256, `${field}.sha256`),
58
- ...(Array.isArray(value.styleGroups) && value.styleGroups.every((item) => typeof item === "string")
110
+ ...(typeof value.size === "number" && Number.isSafeInteger(value.size) && value.size > 0
111
+ ? { size: value.size }
112
+ : {}),
113
+ ...(Array.isArray(value.styleGroups) &&
114
+ value.styleGroups.every((item) => typeof item === "string")
59
115
  ? { styleGroups: value.styleGroups }
60
116
  : {}),
61
- ...(typeof value.styleGroupCount === "number" ? { styleGroupCount: value.styleGroupCount } : {}),
117
+ ...(typeof value.styleGroupCount === "number"
118
+ ? { styleGroupCount: value.styleGroupCount }
119
+ : {}),
62
120
  ...(Array.isArray(value.targets)
63
121
  ? { targets: value.targets.map((item) => requireTarget(item, `${field}.targets[]`)) }
64
122
  : {}),
65
123
  ...(value.targetMetadata !== undefined
66
124
  ? { targetMetadata: parseTargetMetadata(value.targetMetadata, `${field}.targetMetadata`) }
67
125
  : {}),
126
+ ...(value.metadata !== undefined
127
+ ? { metadata: parseMetadataRef(value.metadata, `${field}.metadata`) }
128
+ : {}),
129
+ ...(value.assets !== undefined
130
+ ? { assets: parseAssetsRef(value.assets, `${field}.assets`) }
131
+ : {}),
68
132
  };
69
133
  }
70
134
  /** Parse release-descriptor.json. Never invent free.filename. */
@@ -83,18 +147,56 @@ export function parseReleaseDescriptor(bytes) {
83
147
  }
84
148
  if (!isRecord(parsed.catalog))
85
149
  throw new Error("release-descriptor.json missing catalog");
150
+ const channel = parsed.channel === "local-test" ? "local-test" : undefined;
151
+ const publishable = parsed.publishable === true || parsed.publishable === false ? parsed.publishable : undefined;
86
152
  return {
87
153
  fullVersion: parsed.fullVersion,
88
- free: parseTier(parsed.free, "free"),
154
+ free: parseTier(parsed.free, "free", parsed.fullVersion),
89
155
  catalog: {
90
156
  filename: requireFilename(parsed.catalog.filename, "catalog.filename"),
91
157
  sha256: requireSha(parsed.catalog.sha256, "catalog.sha256"),
92
158
  schemaVersion: typeof parsed.catalog.schemaVersion === "number" ? parsed.catalog.schemaVersion : 1,
93
- ...(typeof parsed.catalog.iconCount === "number" ? { iconCount: parsed.catalog.iconCount } : {}),
94
- ...(typeof parsed.catalog.styleGroupCount === "number" ? { styleGroupCount: parsed.catalog.styleGroupCount } : {}),
159
+ ...(typeof parsed.catalog.iconCount === "number"
160
+ ? { iconCount: parsed.catalog.iconCount }
161
+ : {}),
162
+ ...(typeof parsed.catalog.styleGroupCount === "number"
163
+ ? { styleGroupCount: parsed.catalog.styleGroupCount }
164
+ : {}),
95
165
  },
166
+ ...(channel ? { channel } : {}),
167
+ ...(publishable !== undefined ? { publishable } : {}),
168
+ ...(typeof parsed.schemaVersion === "number" ? { schemaVersion: parsed.schemaVersion } : {}),
169
+ ...(typeof parsed.baseVersion === "string" ? { baseVersion: parsed.baseVersion } : {}),
96
170
  };
97
171
  }
172
+ /** True when a fullVersion is a `X.Y.Z-test` local candidate version. */
173
+ export function isLocalTestVersion(fullVersion) {
174
+ return LOCAL_TEST_VERSION.test(fullVersion);
175
+ }
176
+ /**
177
+ * Reject local-test candidate descriptors unless they are explicitly local and
178
+ * non-publishable: an HTTP/remote source, a missing `local-test` channel, or
179
+ * `publishable: true` must never be accepted by the free install flow.
180
+ */
181
+ export function assertLocalCandidateAllowed(descriptor, viaLocalFixture) {
182
+ if (!isLocalTestVersion(descriptor.fullVersion))
183
+ return;
184
+ if (!viaLocalFixture) {
185
+ throw new Error("local-test candidate descriptors are only accepted from a local release directory");
186
+ }
187
+ if (descriptor.channel !== "local-test") {
188
+ throw new Error("local-test candidate descriptor must declare channel local-test");
189
+ }
190
+ if (descriptor.publishable !== false) {
191
+ throw new Error("local-test candidate descriptor must declare publishable: false");
192
+ }
193
+ if (descriptor.schemaVersion !== 2) {
194
+ throw new Error("local-test candidate descriptor must be schemaVersion 2");
195
+ }
196
+ if (descriptor.baseVersion !== descriptor.fullVersion.replace(/-test$/, "")) {
197
+ throw new Error("local-test candidate descriptor baseVersion must match fullVersion");
198
+ }
199
+ }
98
200
  /** Parse `hex filename` sidecar produced next to the descriptor. */
99
201
  export function parseSha256Sidecar(text) {
100
202
  const token = text.trim().split(/\s+/)[0] ?? "";
@@ -103,7 +205,13 @@ export function parseSha256Sidecar(text) {
103
205
  return token.toLowerCase();
104
206
  }
105
207
  export const PUBLIC_FREE_REPO = { owner: "moewolf-dev", name: "moe-icons" };
106
- export const FREE_DOWNLOAD_HOSTS = ["github.com", "objects.githubusercontent.com"];
208
+ // GitHub Release assets currently redirect through release-assets.githubusercontent.com;
209
+ // keep the legacy objects host for older releases and CDN routing variants.
210
+ export const FREE_DOWNLOAD_HOSTS = [
211
+ "github.com",
212
+ "objects.githubusercontent.com",
213
+ "release-assets.githubusercontent.com",
214
+ ];
107
215
  export function githubReleaseAssetUrl(tag, filename) {
108
216
  if (!tag.startsWith("v"))
109
217
  throw new Error("release tag must be v<fullVersion>");
@@ -1,10 +1,15 @@
1
1
  /**
2
- * G0/G1/G2: styleGroupId vs resourceVariantId.
3
- * Variant ids are deterministic: `<styleGroupId>-<webp|png>-<64|128|256|512>`.
4
- * Parse from the right: size, then format, remainder is styleGroupId.
2
+ * MEDIA-FORMAT-V2: styleGroupId vs resourceVariantId.
3
+ *
4
+ * Canonical variant ids are the actual source directory names:
5
+ * `<styleGroupId>-<imageSize>-<format>` (e.g. `example-bitmap-style-256-webp`). Parsing
6
+ * is controlled keyword-token based, never substring based; `3d` is an ordinary
7
+ * token. The legacy `<group ending -3d>-<format>-<size>` form is only readable
8
+ * through the explicit `parseLegacyResourceVariantId` compatibility branch.
5
9
  */
6
10
  export declare const BITMAP_STYLE_GROUP_RE: RegExp;
7
- export declare const BITMAP_FORMATS: readonly ["webp", "png"];
11
+ export declare const LEGACY_BITMAP_STYLE_GROUP_RE: RegExp;
12
+ export declare const BITMAP_FORMATS: readonly ["png", "webp"];
8
13
  export declare const BITMAP_SIZES: readonly [64, 128, 256, 512];
9
14
  export type BitmapFormat = (typeof BITMAP_FORMATS)[number];
10
15
  export type BitmapSize = (typeof BITMAP_SIZES)[number];
@@ -16,17 +21,29 @@ export interface ResourceVariant {
16
21
  }
17
22
  export declare const DEFAULT_BITMAP_FORMAT: BitmapFormat;
18
23
  export declare const DEFAULT_BITMAP_SIZE: BitmapSize;
19
- export declare function isBitmapStyleGroupId(id: string): boolean;
20
24
  export declare function isBitmapFormat(value: string): value is BitmapFormat;
21
25
  export declare function isBitmapSize(value: number): value is BitmapSize;
22
- /** Build a resourceVariantId; never invent aliases. */
23
- export declare function buildResourceVariantId(styleGroupId: string, format?: BitmapFormat, imageSize?: BitmapSize): string;
26
+ /** Build the canonical v2 resourceVariantId (`<group>-<size>-<format>`). */
27
+ export declare function buildResourceVariantId(styleGroupId: string, format?: string, imageSize?: number): string;
24
28
  /**
25
- * Parse `<styleGroupId>-<format>-<size>` from the right.
26
- * Rejects guessing by scanning for an arbitrary `-3d` substring.
29
+ * Parse a v2 name using complete `-` tokens. Returns undefined when the name is
30
+ * not a bitmap candidate (no format token); throws on duplicate/conflicting
31
+ * keywords; returns undefined when the canonical rebuilt id differs (e.g. the
32
+ * legacy format-before-size order).
27
33
  */
34
+ export declare function parseBitmapName(name: string): ResourceVariant | undefined;
35
+ /** Parse a canonical v2 resourceVariantId or throw. */
28
36
  export declare function parseResourceVariantId(resourceVariantId: string): ResourceVariant;
29
- /** Resolve format/size for a bitmap theme, applying catalog defaults when omitted. */
37
+ /**
38
+ * Explicit v1 compatibility branch: legacy ids are
39
+ * `<styleGroupId ending -3d>-<format>-<size>`. Never guessed by the v2 parser.
40
+ */
41
+ export declare function parseLegacyResourceVariantId(resourceVariantId: string): ResourceVariant;
42
+ /**
43
+ * Build a canonical variant from a group id and optional format/size, applying
44
+ * the schema defaults `webp`/`256`. Availability must still be checked against
45
+ * the catalog by the caller (MEDIA-FORMAT-V2 D-05/B6).
46
+ */
30
47
  export declare function resolveResourceVariant(styleGroupId: string, options?: {
31
48
  readonly format?: string;
32
49
  readonly imageSize?: number;