@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
@@ -1,15 +1,27 @@
1
+ import { isCliError } from "../errors/index.js";
2
+ import { openSync, readSync, closeSync } from "node:fs";
3
+ import { parseResourceIndex, planSelectedResources, downloadSelectedResources, } from "./selected-resources.js";
4
+ import { parseCatalog } from "../catalog/catalog.js";
5
+ import { validateConfigDocument, } from "../project/config.js";
1
6
  import { join } from "node:path";
2
7
  import { createHash } from "node:crypto";
3
8
  import { downloadArtifact, verifyArtifact } from "../project/install.js";
4
- import { decodeUtf8, extractTarGz } from "../project/tar-gz.js";
9
+ import { decodeUtf8, extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
5
10
  import { catalog as bundledCatalog } from "../catalog/catalog.js";
6
- import { cacheKey, FREE_DOWNLOAD_HOSTS, githubReleaseAssetUrl, parseReleaseDescriptor, parseSha256Sidecar, } from "./release-descriptor.js";
11
+ import { cacheArtifact } from "./cache.js";
12
+ import { extractAndVerifyMetadataArchive } from "../metadata/archive.js";
13
+ import { assertLocalCandidateAllowed, cacheKey, FREE_DOWNLOAD_HOSTS, githubReleaseAssetUrl, parseReleaseDescriptor, parseSha256Sidecar, } from "./release-descriptor.js";
7
14
  export const DESCRIPTOR_MAX_BYTES = 256 * 1024;
8
15
  export const ARTIFACT_MAX_BYTES = 64 * 1024 * 1024;
9
- export const DOWNLOAD_TIMEOUT_MS = 30_000;
16
+ export const METADATA_MAX_BYTES = 8 * 1024 * 1024;
17
+ // GitHub Release downloads can be slow on constrained or distant networks.
18
+ // Keep the operation bounded, but allow enough time for the 64 MiB Free limit.
19
+ export const DOWNLOAD_TIMEOUT_MS = 120_000;
10
20
  export const MAX_REDIRECTS = 5;
11
21
  export const DESCRIPTOR_NAME = "release-descriptor.json";
12
22
  export const DESCRIPTOR_SHA_NAME = "release-descriptor.json.sha256";
23
+ export const RELEASE_LATEST_NAME = "release-latest.json";
24
+ export const MAX_DOWNLOAD_ATTEMPTS = 3;
13
25
  function sha256Hex(bytes) {
14
26
  return createHash("sha256").update(bytes).digest("hex");
15
27
  }
@@ -38,6 +50,17 @@ function artifactLimits(cliVersion, onProgress, fixtureBaseUrl, timeoutMs = DOWN
38
50
  ...(onProgress ? { onProgress } : {}),
39
51
  };
40
52
  }
53
+ function metadataLimits(cliVersion, onProgress, fixtureBaseUrl, timeoutMs = DOWNLOAD_TIMEOUT_MS) {
54
+ return {
55
+ maxBytes: METADATA_MAX_BYTES,
56
+ timeoutMs,
57
+ maxRedirects: MAX_REDIRECTS,
58
+ allowedHosts: fixtureBaseUrl ? [new URL(fixtureBaseUrl).host] : FREE_DOWNLOAD_HOSTS,
59
+ ...(fixtureBaseUrl ? { allowHttpLoopback: true } : {}),
60
+ userAgent: userAgent(cliVersion),
61
+ ...(onProgress ? { onProgress } : {}),
62
+ };
63
+ }
41
64
  function mapDownloadError(code, message) {
42
65
  if (code === "HTTP_ERROR" && / 404$/.test(message)) {
43
66
  return { ok: false, reason: "not-found", message };
@@ -49,12 +72,50 @@ function mapDownloadError(code, message) {
49
72
  function readFixture(io, name) {
50
73
  return io.readFileSync(join(io.fixtureDir ?? "", name));
51
74
  }
75
+ function parseLatestDescriptorSha(bytes, sourceVersion) {
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(decodeUtf8(bytes));
79
+ }
80
+ catch {
81
+ throw new Error("release-latest.json is not valid JSON");
82
+ }
83
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
84
+ throw new Error("release-latest.json must be an object");
85
+ }
86
+ const latest = parsed;
87
+ if (latest.schemaVersion !== 1 ||
88
+ latest.tier !== "free" ||
89
+ latest.fullVersion !== sourceVersion) {
90
+ throw new Error("release-latest.json identity does not match the requested Free release");
91
+ }
92
+ if (typeof latest.descriptorSha256 !== "string" ||
93
+ !/^[a-f0-9]{64}$/i.test(latest.descriptorSha256)) {
94
+ throw new Error("release-latest.json descriptorSha256 must be a 64-character SHA-256 hex digest");
95
+ }
96
+ return latest.descriptorSha256.toLowerCase();
97
+ }
52
98
  export function artifactCachePath(cacheDir, fullVersion, sha256) {
53
99
  return join(cacheDir, ...cacheKey(fullVersion, sha256).split("/"), "artifact.tgz");
54
100
  }
101
+ export function metadataCachePath(cacheDir, fullVersion, sha256) {
102
+ return join(cacheDir, ...cacheKey(fullVersion, sha256).split("/"), "metadata.tgz");
103
+ }
55
104
  function cachePath(io, fullVersion, sha256) {
56
105
  return artifactCachePath(io.cacheDir, fullVersion, sha256);
57
106
  }
107
+ /** Best-effort disk-space preflight; missing statfs support is treated as "enough". */
108
+ export function hasEnoughFreeSpace(io, dir, requiredBytes) {
109
+ try {
110
+ const state = io.statfs?.(dir);
111
+ if (state)
112
+ return state.availableBytes >= requiredBytes;
113
+ }
114
+ catch {
115
+ // statfs unsupported or the dir does not exist yet; assume enough.
116
+ }
117
+ return true;
118
+ }
58
119
  async function loadBytes(io, filename, limits, tag) {
59
120
  if (io.signal.aborted)
60
121
  return { ok: false, reason: "cancelled", message: "download cancelled" };
@@ -63,17 +124,35 @@ async function loadBytes(io, filename, limits, tag) {
63
124
  return { ok: true, bytes: readFixture(io, filename) };
64
125
  }
65
126
  catch (error) {
66
- return { ok: false, reason: "validation", message: error instanceof Error ? error.message : String(error) };
127
+ return {
128
+ ok: false,
129
+ reason: "validation",
130
+ message: error instanceof Error ? error.message : String(error),
131
+ };
67
132
  }
68
133
  }
69
- const url = io.fixtureBaseUrl ? new URL(filename, io.fixtureBaseUrl.endsWith("/") ? io.fixtureBaseUrl : `${io.fixtureBaseUrl}/`).toString() : githubReleaseAssetUrl(tag, filename);
70
- const result = await downloadArtifact(url, limits, { fetchFn: io.fetchFn, signal: io.signal });
71
- if (!result.ok)
72
- return mapDownloadError(result.code, result.message);
73
- return { ok: true, bytes: result.bytes };
134
+ const url = io.fixtureBaseUrl
135
+ ? new URL(filename, io.fixtureBaseUrl.endsWith("/") ? io.fixtureBaseUrl : `${io.fixtureBaseUrl}/`).toString()
136
+ : githubReleaseAssetUrl(tag, filename);
137
+ for (let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt += 1) {
138
+ const result = await downloadArtifact(url, limits, { fetchFn: io.fetchFn, signal: io.signal });
139
+ if (result.ok)
140
+ return { ok: true, bytes: result.bytes };
141
+ if (result.code !== "NETWORK_ERROR" || io.signal.aborted || attempt === MAX_DOWNLOAD_ATTEMPTS) {
142
+ return mapDownloadError(result.code, result.message);
143
+ }
144
+ }
145
+ return {
146
+ ok: false,
147
+ reason: "network",
148
+ message: `download failed after ${MAX_DOWNLOAD_ATTEMPTS} attempts`,
149
+ };
74
150
  }
75
151
  function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
76
- const unpacked = extractTarGz(artifactBytes, { maxEntries: 20_000, maxExpandedBytes: ARTIFACT_MAX_BYTES });
152
+ const unpacked = extractTarGz(artifactBytes, {
153
+ maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
154
+ maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
155
+ });
77
156
  if (unpacked.errors.length > 0) {
78
157
  return { ok: false, reason: "validation", message: unpacked.errors[0] ?? "extract failed" };
79
158
  }
@@ -92,27 +171,130 @@ function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
92
171
  return { ok: true, json: decodeUtf8(catalogBytes) };
93
172
  }
94
173
  /**
95
- * Fetch descriptor sidecar → verify descriptor → download free.filename from
96
- * the descriptor (never guessed) → verify archive + nested catalog checksums.
97
- * Local `fixtureDir` stands in for a GitHub Release directory.
174
+ * Download the small metadata archive, verify the whole-archive SHA-256 plus
175
+ * size and every nested file digest, then cache it atomically.
176
+ */
177
+ export async function downloadMetadataArchive(io, metadataRef, expectedCatalogSha, expectedTier, expectedVersion, tag, allowLocalTest = false) {
178
+ const cachedPath = metadataCachePath(io.cacheDir, expectedVersion, metadataRef.sha256);
179
+ if (io.existsSync(cachedPath)) {
180
+ const cached = io.readFileSync(cachedPath);
181
+ const verified = verifyArtifact(cached, metadataRef.sha256);
182
+ if (verified.ok && cached.byteLength === metadataRef.size) {
183
+ const extracted = extractAndVerifyMetadataArchive(cached, {
184
+ expectedCatalogSha,
185
+ expectedTier,
186
+ expectedVersion,
187
+ perFileDigests: metadataRef.files,
188
+ allowLocalTest,
189
+ });
190
+ if (extracted.kind === "ok") {
191
+ return {
192
+ ok: true,
193
+ value: { ...extracted.value, metadataSha256: metadataRef.sha256 },
194
+ cacheHit: true,
195
+ };
196
+ }
197
+ }
198
+ }
199
+ if (!hasEnoughFreeSpace(io, io.cacheDir, metadataRef.size)) {
200
+ return {
201
+ ok: false,
202
+ reason: "disk-full",
203
+ message: `not enough free disk space for ${metadataRef.size} bytes of metadata`,
204
+ };
205
+ }
206
+ const downloaded = await loadBytes(io, metadataRef.filename, metadataLimits(io.cliVersion, io.onProgress, io.fixtureBaseUrl, io.timeoutMs), tag);
207
+ if (!downloaded.ok) {
208
+ if (!io.fixtureDir && downloaded.reason === "network") {
209
+ return { ok: false, reason: "offline-no-cache", message: downloaded.message };
210
+ }
211
+ return downloaded;
212
+ }
213
+ const verified = verifyArtifact(downloaded.bytes, metadataRef.sha256);
214
+ if (!verified.ok) {
215
+ return {
216
+ ok: false,
217
+ reason: "checksum-mismatch",
218
+ message: `metadata archive SHA-256 mismatch: expected ${metadataRef.sha256}, got ${verified.actual}`,
219
+ };
220
+ }
221
+ if (downloaded.bytes.byteLength !== metadataRef.size) {
222
+ return {
223
+ ok: false,
224
+ reason: "checksum-mismatch",
225
+ message: `metadata archive size mismatch: expected ${metadataRef.size}, got ${downloaded.bytes.byteLength}`,
226
+ };
227
+ }
228
+ const extracted = extractAndVerifyMetadataArchive(downloaded.bytes, {
229
+ expectedCatalogSha,
230
+ expectedTier,
231
+ expectedVersion,
232
+ perFileDigests: metadataRef.files,
233
+ allowLocalTest,
234
+ });
235
+ if (extracted.kind !== "ok") {
236
+ return { ok: false, reason: extracted.reason, message: extracted.message };
237
+ }
238
+ try {
239
+ cacheArtifact(io, cachedPath, downloaded.bytes, metadataRef.sha256);
240
+ }
241
+ catch {
242
+ // Cache is best-effort; the verified bytes are still used for this install.
243
+ }
244
+ return {
245
+ ok: true,
246
+ value: { ...extracted.value, metadataSha256: metadataRef.sha256 },
247
+ cacheHit: false,
248
+ };
249
+ }
250
+ /**
251
+ * Fetch the published release index → verify descriptor. Older releases that
252
+ * predate release-latest.json fall back to the descriptor SHA sidecar. Does not
253
+ * download the code artifact; used by metadata-only sync and by the full download.
98
254
  */
99
- export async function downloadFreeRelease(io, sourceVersion) {
255
+ export async function fetchFreeDescriptor(io, sourceVersion) {
100
256
  if (io.signal.aborted)
101
257
  return { ok: false, reason: "cancelled", message: "download cancelled" };
102
258
  const tag = `v${sourceVersion}`;
103
- const shaFile = await loadBytes(io, DESCRIPTOR_SHA_NAME, descriptorLimits(io.cliVersion, io.onProgress, io.fixtureBaseUrl, io.timeoutMs), tag);
104
- if (!shaFile.ok) {
105
- if (!io.fixtureDir && shaFile.reason === "network") {
106
- return { ok: false, reason: "offline-no-cache", message: shaFile.message };
107
- }
108
- return shaFile;
109
- }
259
+ const limits = descriptorLimits(io.cliVersion, io.onProgress, io.fixtureBaseUrl, io.timeoutMs);
260
+ const latestFile = await loadBytes(io, RELEASE_LATEST_NAME, limits, tag);
110
261
  let expectedDescriptorSha;
111
- try {
112
- expectedDescriptorSha = parseSha256Sidecar(decodeUtf8(shaFile.bytes));
262
+ if (latestFile.ok) {
263
+ try {
264
+ expectedDescriptorSha = parseLatestDescriptorSha(latestFile.bytes, sourceVersion);
265
+ }
266
+ catch (error) {
267
+ return {
268
+ ok: false,
269
+ reason: "validation",
270
+ message: error instanceof Error ? error.message : String(error),
271
+ };
272
+ }
113
273
  }
114
- catch (error) {
115
- return { ok: false, reason: "validation", message: error instanceof Error ? error.message : String(error) };
274
+ else {
275
+ if (latestFile.reason !== "not-found") {
276
+ if (!io.fixtureDir && latestFile.reason === "network") {
277
+ return { ok: false, reason: "offline-no-cache", message: latestFile.message };
278
+ }
279
+ return latestFile;
280
+ }
281
+ const shaFile = await loadBytes(io, DESCRIPTOR_SHA_NAME, limits, tag);
282
+ if (!shaFile.ok) {
283
+ if (!io.fixtureDir && shaFile.reason === "network") {
284
+ return { ok: false, reason: "offline-no-cache", message: shaFile.message };
285
+ }
286
+ return shaFile;
287
+ }
288
+ try {
289
+ expectedDescriptorSha = parseSha256Sidecar(decodeUtf8(shaFile.bytes));
290
+ }
291
+ catch (error) {
292
+ return {
293
+ ok: false,
294
+ reason: "validation",
295
+ message: error instanceof Error ? error.message : String(error),
296
+ };
297
+ }
116
298
  }
117
299
  const descriptorFile = await loadBytes(io, DESCRIPTOR_NAME, descriptorLimits(io.cliVersion, io.onProgress, io.fixtureBaseUrl, io.timeoutMs), tag);
118
300
  if (!descriptorFile.ok)
@@ -128,9 +310,14 @@ export async function downloadFreeRelease(io, sourceVersion) {
128
310
  let descriptor;
129
311
  try {
130
312
  descriptor = parseReleaseDescriptor(descriptorFile.bytes);
313
+ assertLocalCandidateAllowed(descriptor, Boolean(io.fixtureDir));
131
314
  }
132
315
  catch (error) {
133
- return { ok: false, reason: "validation", message: error instanceof Error ? error.message : String(error) };
316
+ return {
317
+ ok: false,
318
+ reason: "validation",
319
+ message: error instanceof Error ? error.message : String(error),
320
+ };
134
321
  }
135
322
  if (descriptor.fullVersion !== sourceVersion) {
136
323
  return {
@@ -139,6 +326,164 @@ export async function downloadFreeRelease(io, sourceVersion) {
139
326
  message: `descriptor fullVersion ${descriptor.fullVersion} does not match catalog sourceVersion ${sourceVersion}`,
140
327
  };
141
328
  }
329
+ return { ok: true, descriptor, descriptorSha256: actualDescriptorSha, tag };
330
+ }
331
+ /**
332
+ * Fetch descriptor sidecar → verify descriptor → download free.filename from
333
+ * the descriptor (never guessed) → verify archive + nested catalog checksums →
334
+ * download/verify/cache the matching metadata archive.
335
+ */
336
+ export async function downloadFreeRelease(io, sourceVersion, selection) {
337
+ const fetched = await fetchFreeDescriptor(io, sourceVersion);
338
+ if (!fetched.ok)
339
+ return fetched;
340
+ const { descriptor, descriptorSha256, tag } = fetched;
341
+ const metadataRef = descriptor.free.metadata;
342
+ if (!metadataRef) {
343
+ return {
344
+ ok: false,
345
+ reason: "validation",
346
+ message: "release descriptor is missing free metadata; the release predates metadata support",
347
+ };
348
+ }
349
+ const mode = selection?.config.downloadMode ?? "auto";
350
+ let preflightMetadata;
351
+ if (selection && (mode !== "icons" || descriptor.free.resources)) {
352
+ try {
353
+ const metadata = await downloadMetadataArchive(io, metadataRef, descriptor.catalog.sha256, "free", descriptor.fullVersion, tag, descriptor.channel === "local-test");
354
+ if (!metadata.ok)
355
+ return metadata;
356
+ const strict = validateConfigDocument(selection.document, parseCatalog(JSON.parse(metadata.value.catalogJson)));
357
+ if (strict.kind !== "ok")
358
+ return {
359
+ ok: false,
360
+ reason: "validation",
361
+ message: strict.kind === "invalid" ? strict.message : `config state: ${strict.kind}`,
362
+ };
363
+ preflightMetadata = metadata.value;
364
+ }
365
+ catch (error) {
366
+ return {
367
+ ok: false,
368
+ reason: io.signal.aborted ? "cancelled" : "validation",
369
+ message: error instanceof Error ? error.message : String(error),
370
+ };
371
+ }
372
+ }
373
+ if (selection && mode !== "full" && descriptor.free.resources) {
374
+ try {
375
+ const metadata = preflightMetadata
376
+ ? { ok: true, value: preflightMetadata, cacheHit: true }
377
+ : await downloadMetadataArchive(io, metadataRef, descriptor.catalog.sha256, "free", descriptor.fullVersion, tag, descriptor.channel === "local-test");
378
+ if (!metadata.ok)
379
+ return metadata;
380
+ const catalog = parseCatalog(JSON.parse(metadata.value.catalogJson));
381
+ const strict = validateConfigDocument(selection.document, catalog);
382
+ if (strict.kind !== "ok")
383
+ return {
384
+ ok: false,
385
+ reason: "validation",
386
+ message: strict.kind === "invalid" ? strict.message : `config state: ${strict.kind}`,
387
+ };
388
+ const refs = descriptor.free.resources;
389
+ const indexCache = join(io.cacheDir, "resources", "free", descriptor.fullVersion, refs.index.sha256, "index.json.gz");
390
+ let indexBytes = io.existsSync(indexCache)
391
+ ? io.readFileSync(indexCache)
392
+ : undefined;
393
+ if (!indexBytes ||
394
+ indexBytes.length !== refs.index.size ||
395
+ sha256Hex(indexBytes) !== refs.index.sha256) {
396
+ const downloaded = await loadBytes(io, refs.index.filename, {
397
+ ...metadataLimits(io.cliVersion, undefined, io.fixtureBaseUrl, io.timeoutMs),
398
+ maxBytes: 8 * 1024 * 1024,
399
+ }, tag);
400
+ if (!downloaded.ok)
401
+ return downloaded;
402
+ indexBytes = downloaded.bytes;
403
+ }
404
+ const index = parseResourceIndex(indexBytes, refs, {
405
+ version: descriptor.fullVersion,
406
+ tier: "free",
407
+ artifactSha256: descriptor.free.sha256,
408
+ });
409
+ const plan = planSelectedResources(strict.config, catalog, index);
410
+ selection.onPlan?.(`Download plan: ${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("; ")}` : ""}`);
411
+ cacheArtifact(io, indexCache, indexBytes, refs.index.sha256);
412
+ const fetched = await downloadSelectedResources(index, refs.index.sha256, plan.paths, {
413
+ io,
414
+ cacheDir: io.cacheDir,
415
+ fetch: io.fetchFn,
416
+ signal: io.signal,
417
+ allowedHosts: io.fixtureBaseUrl ? [new URL(io.fixtureBaseUrl).host] : FREE_DOWNLOAD_HOSTS,
418
+ ...(io.fixtureBaseUrl ? { allowLoopback: true } : {}),
419
+ getBundleUrl: () => Promise.resolve(io.fixtureBaseUrl
420
+ ? new URL(refs.bundle.filename, io.fixtureBaseUrl.endsWith("/") ? io.fixtureBaseUrl : `${io.fixtureBaseUrl}/`).toString()
421
+ : githubReleaseAssetUrl(tag, refs.bundle.filename)),
422
+ ...(io.fixtureDir
423
+ ? {
424
+ readRange: (start, size) => {
425
+ const fd = openSync(join(io.fixtureDir, refs.bundle.filename), "r");
426
+ try {
427
+ const bytes = Buffer.alloc(size);
428
+ let read = 0;
429
+ while (read < size) {
430
+ const count = readSync(fd, bytes, read, size - read, start + read);
431
+ if (!count)
432
+ throw new Error("truncated resource bundle");
433
+ read += count;
434
+ }
435
+ return Promise.resolve(bytes);
436
+ }
437
+ finally {
438
+ closeSync(fd);
439
+ }
440
+ },
441
+ }
442
+ : {}),
443
+ });
444
+ return {
445
+ ok: true,
446
+ descriptor,
447
+ descriptorSha256,
448
+ tag,
449
+ artifactBytes: new Uint8Array(),
450
+ cacheHit: fetched.cacheHits === plan.paths.length,
451
+ catalogJson: metadata.value.catalogJson,
452
+ manifestJson: metadata.value.manifestJson,
453
+ manualMd: metadata.value.manualMd,
454
+ metadataSha256: metadata.value.metadataSha256,
455
+ selected: {
456
+ ...fetched,
457
+ refs,
458
+ indexBytes,
459
+ payloadBytes: plan.payloadBytes,
460
+ fallbacks: plan.fallbacks,
461
+ },
462
+ };
463
+ }
464
+ catch (error) {
465
+ return {
466
+ ok: false,
467
+ reason: io.signal.aborted || (isCliError(error) && error.code === "CANCELLED")
468
+ ? "cancelled"
469
+ : isCliError(error) && error.code === "DISK_FULL"
470
+ ? "disk-full"
471
+ : isCliError(error) && error.code === "NETWORK_ERROR"
472
+ ? "network"
473
+ : "validation",
474
+ message: error instanceof Error ? error.message : String(error),
475
+ };
476
+ }
477
+ }
478
+ if (mode === "icons")
479
+ return {
480
+ ok: false,
481
+ reason: "validation",
482
+ message: "this release has no selected-resource index; choose a newer release or explicitly set downloadMode=full",
483
+ };
484
+ selection?.onPlan?.(mode === "full"
485
+ ? "Download mode: full (explicitly configured); the complete target archive will be verified before selection."
486
+ : "This release does not advertise selected downloads; auto uses the verified full archive. Set downloadMode=icons to require selected downloads.");
142
487
  const artifactCache = cachePath(io, descriptor.fullVersion, descriptor.free.sha256);
143
488
  if (io.existsSync(artifactCache)) {
144
489
  const cached = io.readFileSync(artifactCache);
@@ -147,17 +492,32 @@ export async function downloadFreeRelease(io, sourceVersion) {
147
492
  const catalog = catalogFromArchive(cached, descriptor.catalog.filename, descriptor.catalog.sha256);
148
493
  if (!catalog.ok)
149
494
  return catalog;
495
+ const metadata = preflightMetadata
496
+ ? { ok: true, value: preflightMetadata, cacheHit: true }
497
+ : await downloadMetadataArchive(io, metadataRef, descriptor.catalog.sha256, "free", descriptor.fullVersion, tag, descriptor.channel === "local-test");
498
+ if (!metadata.ok)
499
+ return metadata;
150
500
  return {
151
501
  ok: true,
152
502
  descriptor,
153
- descriptorSha256: actualDescriptorSha,
503
+ descriptorSha256,
154
504
  artifactBytes: cached,
155
505
  catalogJson: catalog.json,
156
506
  cacheHit: true,
157
507
  tag,
508
+ metadataSha256: metadata.value.metadataSha256,
509
+ manifestJson: metadata.value.manifestJson,
510
+ manualMd: metadata.value.manualMd,
158
511
  };
159
512
  }
160
513
  }
514
+ if (!hasEnoughFreeSpace(io, io.cacheDir, ARTIFACT_MAX_BYTES + METADATA_MAX_BYTES)) {
515
+ return {
516
+ ok: false,
517
+ reason: "disk-full",
518
+ message: "not enough free disk space to cache the icon library",
519
+ };
520
+ }
161
521
  const artifact = await loadBytes(io, descriptor.free.filename, artifactLimits(io.cliVersion, io.onProgress, io.fixtureBaseUrl, io.timeoutMs), tag);
162
522
  if (!artifact.ok) {
163
523
  if (!io.fixtureDir && artifact.reason === "network") {
@@ -176,16 +536,28 @@ export async function downloadFreeRelease(io, sourceVersion) {
176
536
  const catalog = catalogFromArchive(artifact.bytes, descriptor.catalog.filename, descriptor.catalog.sha256);
177
537
  if (!catalog.ok)
178
538
  return catalog;
179
- io.mkdirSync(join(artifactCache, ".."));
180
- io.writeFileSync(artifactCache, artifact.bytes);
539
+ const metadata = preflightMetadata
540
+ ? { ok: true, value: preflightMetadata, cacheHit: true }
541
+ : await downloadMetadataArchive(io, metadataRef, descriptor.catalog.sha256, "free", descriptor.fullVersion, tag, descriptor.channel === "local-test");
542
+ if (!metadata.ok)
543
+ return metadata;
544
+ try {
545
+ cacheArtifact(io, artifactCache, artifact.bytes, descriptor.free.sha256);
546
+ }
547
+ catch {
548
+ // Cache is best-effort; verified bytes are still used for this install.
549
+ }
181
550
  return {
182
551
  ok: true,
183
552
  descriptor,
184
- descriptorSha256: actualDescriptorSha,
553
+ descriptorSha256,
185
554
  artifactBytes: artifact.bytes,
186
555
  catalogJson: catalog.json,
187
556
  cacheHit: false,
188
557
  tag,
558
+ metadataSha256: metadata.value.metadataSha256,
559
+ manifestJson: metadata.value.manifestJson,
560
+ manualMd: metadata.value.manualMd,
189
561
  };
190
562
  }
191
563
  export function bundledSourceVersion() {
@@ -1,5 +1,7 @@
1
1
  import { type TransactionalFsWithCopy } from "../project/install.js";
2
+ import { type IconCatalog } from "../catalog/catalog.js";
2
3
  import type { CommandContext } from "./context.js";
4
+ import { type InstallMetadataParseOptions } from "../project/install-metadata.js";
3
5
  import type { Target } from "../commands/parser.js";
4
6
  export type GenerateResult = {
5
7
  readonly ok: true;
@@ -12,6 +14,32 @@ export type GenerateResult = {
12
14
  readonly errors?: readonly string[];
13
15
  readonly code?: string;
14
16
  };
17
+ /**
18
+ * B7A/B5: prefer the catalog written by install / pro-install / library update
19
+ * (`.moeicons/catalog.json`) over the bundled catalog. The installed catalog
20
+ * carries the resourceVersion's real style groups (including migrated
21
+ * `moe-colored` free+pro and bitmap variants), so generate/reconcile must not
22
+ * validate against a stale bundled allowlist. Missing/corrupt catalogs fall
23
+ * back to the bundled catalog; the reconcile path still hash-verifies it.
24
+ */
25
+ export type InstalledCatalogState = {
26
+ readonly status: "absent";
27
+ } | {
28
+ readonly status: "ok";
29
+ readonly catalog: IconCatalog;
30
+ } | {
31
+ readonly status: "invalid";
32
+ readonly message: string;
33
+ };
34
+ /**
35
+ * B7A/B5/DEV-20-02: load the catalog written by install / pro-install / library
36
+ * update and verify it against the install metadata hash. A missing catalog
37
+ * falls back to the bundled contract; a corrupt or drifting one must surface a
38
+ * repair/reinstall error instead of silently switching entitlement semantics.
39
+ */
40
+ export declare function loadInstalledCatalogState(projectRoot: string, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync">, opts?: InstallMetadataParseOptions): InstalledCatalogState;
41
+ /** Backwards-compatible accessor used by tests: returns the catalog or undefined. */
42
+ export declare function loadInstalledCatalog(projectRoot: string, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync">): IconCatalog | undefined;
15
43
  /**
16
44
  * Load installed artifact bytes for generate. Prefers `.moeicons/artifact/<target>/`
17
45
  * when that subtree already contains the assets the generator needs (assets target).
@@ -29,5 +57,6 @@ export declare function runGenerateUseCase(context: CommandContext, fs_: Transac
29
57
  readonly noTailwind?: boolean;
30
58
  readonly target?: Target;
31
59
  readonly archiveFiles?: Readonly<Record<string, Uint8Array>>;
60
+ /** Kept for older callers; installed resources always reconcile through metadata. */
32
61
  readonly reconcileInstalled?: boolean;
33
62
  }): Promise<GenerateResult>;