@moewolf/moe-icons-cli 0.0.1 → 0.0.3

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 +249 -10
  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 +12768 -3965
  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,25 +1,119 @@
1
1
  import { join, relative, resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
- import { executeGeneratedFilesDir, executeManagedReconcile, } from "../project/install.js";
3
+ import { executeManagedReconcile, safeManagedPath, } from "../project/install.js";
4
4
  import { detectProject } from "../project/detect.js";
5
- import { readMoeiconsConfig } from "../project/config.js";
5
+ import { readMoeiconsConfig, loadConfigDocument, } from "../project/config.js";
6
+ import { catalog as defaultCatalog, parseCatalog } from "../catalog/catalog.js";
7
+ import { toProxyName } from "./icon-names.js";
6
8
  import { planGeneratedFiles } from "../generator/generate.js";
7
9
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
8
- import { CliError, isCliError } from "../errors/index.js";
9
- import { extractTarGz } from "../project/tar-gz.js";
10
+ import { isCliError } from "../errors/index.js";
11
+ import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
10
12
  import { artifactCachePath } from "./free-download.js";
11
13
  import { resolveThemes } from "../generator/theme-resolve.js";
14
+ import { loadPinnedBitmapShardAssets } from "./bitmap-shard-resolver.js";
12
15
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
13
16
  import { withProjectLockSync } from "../project/project-lock.js";
14
- function hasBitmapThemes(config) {
15
- const resolved = resolveThemes(config);
17
+ import { allowLocalTestFromEnv } from "./local-test-env.js";
18
+ import { resolveIconTheme, themeHasIcon } from "./icon-selection.js";
19
+ function hasBitmapThemes(config, sourceCatalog) {
20
+ const resolved = resolveThemes(config, sourceCatalog);
16
21
  return resolved.ok && resolved.themes.some((theme) => theme.kind === "bitmap");
17
22
  }
23
+ /**
24
+ * B7A/B5/DEV-20-02: load the catalog written by install / pro-install / library
25
+ * update and verify it against the install metadata hash. A missing catalog
26
+ * falls back to the bundled contract; a corrupt or drifting one must surface a
27
+ * repair/reinstall error instead of silently switching entitlement semantics.
28
+ */
29
+ export function loadInstalledCatalogState(projectRoot, fs_, opts = {}) {
30
+ const catalogPath = join(projectRoot, ".moeicons", "catalog.json");
31
+ const metadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
32
+ const catalogExists = fs_.existsSync(catalogPath);
33
+ const metadataExists = fs_.existsSync(metadataPath);
34
+ // FIX-22-B: an installed project (metadata present) must have a parseable,
35
+ // hash-consistent catalog. Only a truly un-installed project falls back to
36
+ // the bundled contract.
37
+ if (!catalogExists) {
38
+ if (metadataExists) {
39
+ return {
40
+ status: "invalid",
41
+ message: "install metadata exists but the catalog is missing; run 'moeicons install' to repair or reinstall",
42
+ };
43
+ }
44
+ return { status: "absent" };
45
+ }
46
+ let catalog;
47
+ let catalogBytes;
48
+ try {
49
+ catalogBytes = fs_.readFileSync(catalogPath, "utf8");
50
+ if (typeof catalogBytes !== "string")
51
+ return { status: "invalid", message: "installed catalog is not text" };
52
+ catalog = parseCatalog(JSON.parse(catalogBytes));
53
+ }
54
+ catch (error) {
55
+ return {
56
+ status: "invalid",
57
+ message: `installed catalog cannot be parsed: ${error instanceof Error ? error.message : String(error)}; run 'moeicons install' to repair or reinstall`,
58
+ };
59
+ }
60
+ if (!metadataExists) {
61
+ return {
62
+ status: "invalid",
63
+ message: "installed catalog is present without install metadata; run 'moeicons install' to repair or reinstall",
64
+ };
65
+ }
66
+ let metadata;
67
+ try {
68
+ const rawMetadata = fs_.readFileSync(metadataPath, "utf8");
69
+ if (typeof rawMetadata !== "string")
70
+ throw new Error("metadata is not text");
71
+ metadata = parseInstallMetadata(rawMetadata, opts);
72
+ }
73
+ catch (error) {
74
+ return {
75
+ status: "invalid",
76
+ message: `install metadata is invalid: ${error instanceof Error ? error.message : String(error)}; run 'moeicons install' to repair or reinstall`,
77
+ };
78
+ }
79
+ if (!metadata) {
80
+ return {
81
+ status: "invalid",
82
+ message: "install metadata could not be parsed; run 'moeicons install' to repair or reinstall",
83
+ };
84
+ }
85
+ const actual = sha256Bytes(catalogBytes);
86
+ const managed = metadata.managedFiles?.[".moeicons/catalog.json"];
87
+ if (typeof metadata.catalogSha256 !== "string" || typeof managed !== "string") {
88
+ return {
89
+ status: "invalid",
90
+ message: "install metadata is missing the catalog digest; run 'moeicons install' to repair or reinstall",
91
+ };
92
+ }
93
+ if (metadata.catalogSha256 !== managed) {
94
+ return {
95
+ status: "invalid",
96
+ message: "install metadata catalog digest is inconsistent; run 'moeicons install' to repair or reinstall",
97
+ };
98
+ }
99
+ if (actual !== metadata.catalogSha256) {
100
+ return {
101
+ status: "invalid",
102
+ message: "installed catalog hash does not match install metadata; run 'moeicons install' to repair or reinstall",
103
+ };
104
+ }
105
+ return { status: "ok", catalog };
106
+ }
107
+ /** Backwards-compatible accessor used by tests: returns the catalog or undefined. */
108
+ export function loadInstalledCatalog(projectRoot, fs_) {
109
+ const state = loadInstalledCatalogState(projectRoot, fs_);
110
+ return state.status === "ok" ? state.catalog : undefined;
111
+ }
18
112
  /** Vanilla/Assets generate from raw SVG; bitmap themes need variant binaries. */
19
- function needsArchiveFiles(config) {
113
+ function needsArchiveFiles(config, sourceCatalog) {
20
114
  if (config.target === "vanilla" || config.target === "assets")
21
115
  return true;
22
- return hasBitmapThemes(config);
116
+ return hasBitmapThemes(config, sourceCatalog);
23
117
  }
24
118
  function archiveHasAssetsManifest(files) {
25
119
  return Object.keys(files).some((path) => /(?:^|\/)assets\/manifest\.json$/.test(path));
@@ -57,6 +151,87 @@ function readBinaryFile(fs_, path) {
57
151
  const data = fs_.readFileSync(path);
58
152
  return typeof data === "string" ? Buffer.from(data, "utf8") : new Uint8Array(data);
59
153
  }
154
+ /** Unique bitmap variants the config selects, in canonical order. */
155
+ function requiredBitmapVariants(config, sourceCatalog) {
156
+ const resolved = resolveThemes(config, sourceCatalog);
157
+ if (!resolved.ok)
158
+ return [];
159
+ const variants = resolved.themes
160
+ .filter((theme) => theme.kind === "bitmap" && theme.variant)
161
+ .flatMap((theme) => (theme.variant ? [theme.variant] : []));
162
+ return [...new Map(variants.map((variant) => [variant.resourceVariantId, variant])).values()];
163
+ }
164
+ function archiveHasVariantAssets(archiveFiles, variant, icons) {
165
+ return icons.every((iconId) => archiveFiles[`assets/${variant.resourceVariantId}/${iconId}.${variant.format}`] !== undefined);
166
+ }
167
+ /**
168
+ * DEV-G07: a v4 code archive carries no bitmap payload, so bitmap variants are
169
+ * restored from the pinned shards. Only the config's selected tuples are read;
170
+ * each cached shard is re-verified against its pinned identity (archive SHA,
171
+ * manifest SHA, size, dimensions) before any bytes are handed to the generator.
172
+ */
173
+ function mergePinnedBitmapShardAssets(archiveFiles, effectiveConfig, sourceCatalog, projectRoot, env, fs_) {
174
+ const variants = requiredBitmapVariants(effectiveConfig, sourceCatalog);
175
+ const missing = variants.filter((variant) => !archiveHasVariantAssets(archiveFiles, variant, effectiveConfig.icons.filter((iconId) => Object.entries(effectiveConfig.themes).some(([name, theme]) => theme.styleGroup === variant.styleGroupId &&
176
+ themeHasIcon(effectiveConfig, sourceCatalog ?? defaultCatalog, name, iconId) &&
177
+ (() => {
178
+ const resolved = resolveThemes(effectiveConfig, sourceCatalog);
179
+ return (resolved.ok &&
180
+ resolved.themes.some((item) => item.theme === name &&
181
+ item.variant?.resourceVariantId === variant.resourceVariantId));
182
+ })()))));
183
+ if (missing.length === 0)
184
+ return { ok: true };
185
+ const metadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
186
+ if (!fs_.existsSync(metadataPath)) {
187
+ return {
188
+ ok: false,
189
+ errors: ["bitmap shards require an installed pro artifact; run `moeicons install` first"],
190
+ };
191
+ }
192
+ const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
193
+ allowLocalTest: allowLocalTestFromEnv(env),
194
+ });
195
+ if (!metadata) {
196
+ return {
197
+ ok: false,
198
+ errors: ["install metadata is invalid; run 'moeicons install' to repair or reinstall"],
199
+ };
200
+ }
201
+ if (metadata.delivery?.mode === "icons") {
202
+ return {
203
+ ok: false,
204
+ errors: [
205
+ "selected bitmap resources are missing for this configuration; run `moeicons install` to download the configured icons, format and imageSize",
206
+ ],
207
+ };
208
+ }
209
+ if (!metadata.bitmapShards || metadata.bitmapShards.length === 0) {
210
+ return {
211
+ ok: false,
212
+ errors: [
213
+ "bitmap shards are not pinned in install metadata; run 'moeicons install' to repair or reinstall",
214
+ ],
215
+ };
216
+ }
217
+ const missingTuples = missing.map((variant) => ({
218
+ styleGroupId: variant.styleGroupId,
219
+ imageSize: { width: variant.imageSize, height: variant.imageSize },
220
+ format: variant.format,
221
+ }));
222
+ const cacheDir = env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache");
223
+ try {
224
+ const loaded = loadPinnedBitmapShardAssets(metadata.bitmapShards, missingTuples, cacheDir, fs_);
225
+ Object.assign(archiveFiles, loaded.files);
226
+ return { ok: true };
227
+ }
228
+ catch (error) {
229
+ return {
230
+ ok: false,
231
+ errors: [error instanceof Error ? error.message : "bitmap shard verification failed"],
232
+ };
233
+ }
234
+ }
60
235
  /**
61
236
  * Load installed artifact bytes for generate. Prefers `.moeicons/artifact/<target>/`
62
237
  * when that subtree already contains the assets the generator needs (assets target).
@@ -67,15 +242,45 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
67
242
  if (injected)
68
243
  return { ok: true, files: injected };
69
244
  const fixtureTgz = env.MOEICONS_BITMAP_ARCHIVE;
245
+ // DEV-G08: the aggregate-archive fixture is a local-test-only seam. It must
246
+ // never bypass the shard contract in a production context, so a set variable
247
+ // outside a local environment fails closed instead of being silently used.
248
+ if (fixtureTgz && !allowLocalTestFromEnv(env)) {
249
+ return { ok: false, reason: "MOEICONS_BITMAP_ARCHIVE is only honored in a local-test context" };
250
+ }
70
251
  if (fixtureTgz && fs_.existsSync(fixtureTgz)) {
71
252
  const unpacked = extractTarGz(readBinaryFile(fs_, fixtureTgz), {
72
- maxEntries: 20_000,
73
- maxExpandedBytes: 64 * 1024 * 1024,
253
+ maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
254
+ maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
74
255
  });
75
256
  if (unpacked.errors.length > 0)
76
257
  return { ok: false, reason: unpacked.errors[0] ?? "invalid bitmap archive fixture" };
77
258
  return { ok: true, files: unpacked.files };
78
259
  }
260
+ const selectionMetadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
261
+ if (fs_.existsSync(selectionMetadataPath)) {
262
+ const pinned = parseInstallMetadata(fs_.readFileSync(selectionMetadataPath, "utf8"), {
263
+ allowLocalTest: allowLocalTestFromEnv(env),
264
+ });
265
+ if (pinned?.delivery?.mode === "icons") {
266
+ const files = {};
267
+ for (const [name, hash] of Object.entries(pinned.managedFiles)) {
268
+ if (!name.startsWith(".moeicons/artifact/"))
269
+ continue;
270
+ const target = safeManagedPath(projectRoot, name).target;
271
+ if (!fs_.existsSync(target))
272
+ return { ok: false, reason: `selected resource missing: ${name}; run moeicons install` };
273
+ const bytes = readBinaryFile(fs_, target);
274
+ if (sha256Bytes(bytes) !== hash)
275
+ return {
276
+ ok: false,
277
+ reason: `selected resource modified: ${name}; preserve your edit before reinstalling`,
278
+ };
279
+ files[name.slice(".moeicons/artifact/".length)] = bytes;
280
+ }
281
+ return { ok: true, files };
282
+ }
283
+ }
79
284
  const meta = readInstallMetadata(projectRoot, fs_.readFileSync, fs_.existsSync);
80
285
  if (meta?.target) {
81
286
  const subtreeRoot = join(projectRoot, ".moeicons", "artifact", meta.target);
@@ -105,7 +310,8 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
105
310
  walk(subtreeRoot, "");
106
311
  // assets install lands the raw tree; reuse it. Package subtrees (react/vue/
107
312
  // vanilla) do not contain assets/manifest.json — fall through to the cache.
108
- if (Object.keys(files).length > 0 && (meta.target === "assets" || archiveHasAssetsManifest(files))) {
313
+ if (Object.keys(files).length > 0 &&
314
+ (meta.target === "assets" || archiveHasAssetsManifest(files))) {
109
315
  return { ok: true, files };
110
316
  }
111
317
  }
@@ -115,8 +321,8 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
115
321
  const cached = artifactCachePath(cacheDir, meta.artifactVersion, meta.artifactSha256);
116
322
  if (fs_.existsSync(cached)) {
117
323
  const unpacked = extractTarGz(readBinaryFile(fs_, cached), {
118
- maxEntries: 20_000,
119
- maxExpandedBytes: 64 * 1024 * 1024,
324
+ maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
325
+ maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
120
326
  });
121
327
  if (unpacked.errors.length > 0)
122
328
  return { ok: false, reason: unpacked.errors[0] ?? "invalid cached artifact" };
@@ -125,49 +331,46 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
125
331
  }
126
332
  return { ok: false, reason: "installed artifact not found" };
127
333
  }
128
- /**
129
- * B5: switching the configured target over an existing install is a destructive
130
- * migration (generated files are replaced and the install subtree changes).
131
- * Interactive TTY prompts for explicit confirmation; non-interactive/JSON mode
132
- * fails with VALIDATION_ERROR unless `--yes` was passed (the non-interactive
133
- * UI resolves `--yes` confirmations to true). Fresh projects with no install
134
- * metadata never prompt.
135
- */
136
- async function confirmTargetSwitch(context, installedTarget, configuredTarget) {
137
- const message = `Existing install targets "${installedTarget}" but the config now targets "${configuredTarget}". ` +
138
- "Switching targets is a destructive migration that replaces generated files. Continue?";
139
- try {
140
- return (await context.ui.confirm(message, context.signal)) === true;
141
- }
142
- catch (error) {
143
- if (isCliError(error) && error.code === "NOT_TTY") {
144
- throw new CliError("VALIDATION_ERROR", `target switch from "${installedTarget}" to "${configuredTarget}" is destructive and requires confirmation; pass --yes in non-interactive mode`);
145
- }
146
- throw error;
147
- }
148
- }
334
+ // Keep the public asynchronous command contract while all filesystem work is synchronous.
335
+ // eslint-disable-next-line @typescript-eslint/require-await
149
336
  export async function runGenerateUseCase(context, fs_, options = {}) {
150
337
  const project = detectProject(context.cwd);
151
338
  if (!project)
152
339
  return { ok: false, reason: "no-project" };
153
- const loaded = readMoeiconsConfig(project.root);
340
+ const allowLocalTest = allowLocalTestFromEnv(context.env);
341
+ const catalogState = loadInstalledCatalogState(project.root, fs_, { allowLocalTest });
342
+ if (catalogState.status === "invalid") {
343
+ return { ok: false, reason: "validation", errors: [catalogState.message] };
344
+ }
345
+ const sourceCatalog = catalogState.status === "ok" ? catalogState.catalog : undefined;
346
+ const plannedConfigDocument = JSON.stringify(loadConfigDocument(project.root));
347
+ const loaded = readMoeiconsConfig(project.root, sourceCatalog);
348
+ if (loaded.kind === "invalid")
349
+ return { ok: false, reason: "validation", errors: [loaded.message] };
154
350
  if (loaded.kind !== "ok")
155
351
  return { ok: false, reason: `config state: ${loaded.kind}` };
156
352
  const effectiveConfig = options.target
157
353
  ? { ...loaded.config, target: options.target }
158
354
  : loaded.config;
355
+ const plannedMetadataPath = join(project.root, ".moeicons", "install-metadata.json");
356
+ const plannedMetadataText = fs_.existsSync(plannedMetadataPath)
357
+ ? fs_.readFileSync(plannedMetadataPath, "utf8")
358
+ : undefined;
159
359
  const installedMeta = readInstallMetadata(project.root, fs_.readFileSync, fs_.existsSync);
360
+ if (!installedMeta) {
361
+ return {
362
+ ok: false,
363
+ reason: "generate requires an installed, version-pinned icon artifact; run 'moeicons install' first",
364
+ };
365
+ }
160
366
  if (installedMeta?.target && installedMeta.target !== effectiveConfig.target) {
161
- const confirmed = await confirmTargetSwitch(context, installedMeta.target, effectiveConfig.target);
162
- if (!confirmed)
163
- return {
164
- ok: false,
165
- reason: "cancelled",
166
- errors: [`target switch from "${installedMeta.target}" to "${effectiveConfig.target}" cancelled`],
167
- };
367
+ return {
368
+ ok: false,
369
+ reason: `installed artifact targets "${installedMeta.target}" while config targets "${effectiveConfig.target}"; set config target to "${effectiveConfig.target}" and run 'moeicons install ${loaded.config.tier} --target ${effectiveConfig.target}' before generate`,
370
+ };
168
371
  }
169
372
  let archiveFiles = options.archiveFiles;
170
- if (needsArchiveFiles(effectiveConfig) && archiveFiles === undefined) {
373
+ if (needsArchiveFiles(effectiveConfig, sourceCatalog) && archiveFiles === undefined) {
171
374
  const loadedArchive = loadArchiveFiles(project.root, context.env, fs_, undefined);
172
375
  if (!loadedArchive.ok) {
173
376
  return {
@@ -178,11 +381,22 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
178
381
  }
179
382
  archiveFiles = loadedArchive.files;
180
383
  }
181
- const plan = planGeneratedFiles(effectiveConfig, effectiveConfig.outputDir, archiveFiles ? { archiveFiles } : {});
384
+ if (archiveFiles !== undefined) {
385
+ const merged = { ...archiveFiles };
386
+ const shardMerge = mergePinnedBitmapShardAssets(merged, effectiveConfig, sourceCatalog, project.root, context.env, fs_);
387
+ if (!shardMerge.ok)
388
+ return { ok: false, reason: "validation", errors: [...shardMerge.errors] };
389
+ archiveFiles = merged;
390
+ }
391
+ const plan = planGeneratedFiles(effectiveConfig, effectiveConfig.outputDir, {
392
+ ...(archiveFiles ? { archiveFiles } : {}),
393
+ ...(sourceCatalog ? { catalog: sourceCatalog } : {}),
394
+ });
182
395
  if (!plan.ok)
183
396
  return { ok: false, reason: "validation", errors: plan.errors };
184
397
  const notes = [];
185
398
  const sideFiles = [];
399
+ const expectedSideFiles = {};
186
400
  try {
187
401
  const tw = planTailwindIntegration(project.root, loaded.config.outputDir, {
188
402
  noTailwind: options.noTailwind === true,
@@ -190,6 +404,10 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
190
404
  });
191
405
  notes.push(...tw.notes);
192
406
  sideFiles.push(...tw.files);
407
+ for (const file of tw.files)
408
+ expectedSideFiles[relative(project.root, file.path).replace(/\\/g, "/")] = fs_.existsSync(file.path)
409
+ ? sha256Bytes(fs_.readFileSync(file.path))
410
+ : undefined;
193
411
  }
194
412
  catch (error) {
195
413
  if (isCliError(error) && error.code === "TAILWIND_VERSION_UNSUPPORTED") {
@@ -200,6 +418,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
200
418
  const pkgPath = join(project.root, "package.json");
201
419
  if (fs_.existsSync(pkgPath)) {
202
420
  const pkgSource = fs_.readFileSync(pkgPath, "utf8");
421
+ expectedSideFiles["package.json"] = sha256Bytes(pkgSource);
203
422
  const target = effectiveConfig.target;
204
423
  const deps = target === "react" || target === "vue"
205
424
  ? ensureClassMergeDependencies(pkgSource)
@@ -211,21 +430,35 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
211
430
  notes.push(...deps.notes);
212
431
  if (deps.changed) {
213
432
  sideFiles.push({ path: pkgPath, content: deps.nextSource });
433
+ const installCommand = project.packageManager === "pnpm"
434
+ ? "pnpm install"
435
+ : project.packageManager === "yarn"
436
+ ? "yarn install"
437
+ : project.packageManager === "npm"
438
+ ? "npm install"
439
+ : "your package manager's install command";
440
+ notes.push(`package.json changed; run ${installCommand} to update the lockfile and install dependencies`);
214
441
  }
215
442
  }
216
443
  try {
217
- if (options.reconcileInstalled) {
444
+ return withProjectLockSync(project.root, "reload", () => {
445
+ if (JSON.stringify(loadConfigDocument(project.root)) !== plannedConfigDocument)
446
+ return { ok: false, reason: "config changed while preparing generate; retry" };
218
447
  const metadataPath = join(project.root, ".moeicons", "install-metadata.json");
219
448
  if (!fs_.existsSync(metadataPath))
220
449
  return {
221
450
  ok: false,
222
- reason: "managed install metadata is missing; run repair or reinstall",
451
+ reason: "managed install metadata is missing; run 'moeicons install' to repair or reinstall",
223
452
  };
224
- const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"));
453
+ const metadata = parseInstallMetadata(fs_.readFileSync(metadataPath, "utf8"), {
454
+ allowLocalTest,
455
+ });
456
+ if (fs_.readFileSync(metadataPath, "utf8") !== plannedMetadataText)
457
+ return { ok: false, reason: "installation changed while preparing generate; retry" };
225
458
  if (!metadata || metadata.tier !== loaded.config.tier)
226
459
  return {
227
460
  ok: false,
228
- reason: "managed install metadata is invalid or does not match config tier; run repair or reinstall",
461
+ reason: "managed install metadata is invalid or does not match config tier; run 'moeicons install' to repair or reinstall",
229
462
  };
230
463
  for (const [managedPath, expected] of Object.entries(metadata.managedFiles)) {
231
464
  const absolute = join(project.root, managedPath);
@@ -237,17 +470,62 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
237
470
  if (metadata.managedFiles[".moeicons/catalog.json"] !== metadata.catalogSha256)
238
471
  return {
239
472
  ok: false,
240
- reason: "managed catalog hash is inconsistent; run repair or reinstall",
473
+ reason: "managed catalog hash is inconsistent; run 'moeicons install' to repair or reinstall",
241
474
  };
475
+ const hasInstalledComponents = Object.keys(metadata.managedFiles).some((path) => path.startsWith(`.moeicons/artifact/${effectiveConfig.target}/`) && path.endsWith(".js"));
476
+ if (hasInstalledComponents &&
477
+ (effectiveConfig.target === "react" || effectiveConfig.target === "vue")) {
478
+ const resolved = resolveThemes(effectiveConfig, sourceCatalog);
479
+ if (!resolved.ok)
480
+ return { ok: false, reason: resolved.errors.join("; ") };
481
+ for (const iconId of effectiveConfig.icons) {
482
+ for (const theme of resolved.themes) {
483
+ const selected = resolveIconTheme(effectiveConfig, sourceCatalog, theme.theme, iconId);
484
+ if (!selected)
485
+ return {
486
+ ok: false,
487
+ reason: `icon "${iconId}" has no selected variant for theme "${theme.theme}"`,
488
+ };
489
+ const group = effectiveConfig.themes[selected].styleGroup;
490
+ const kind = resolved.themes.find((candidate) => candidate.entry.styleGroup === group)?.kind;
491
+ if (kind === "bitmap")
492
+ continue;
493
+ const suffix = effectiveConfig.target === "vue" ? ".vue.js" : ".js";
494
+ const rel = `.moeicons/artifact/${effectiveConfig.target}/${group}/${toProxyName(iconId)}${suffix}`;
495
+ if (!fs_.existsSync(join(project.root, rel))) {
496
+ return {
497
+ ok: false,
498
+ reason: `icon "${iconId}" is configured but its ${group} component is not installed (${rel}); run 'moeicons install' before generate`,
499
+ };
500
+ }
501
+ }
502
+ }
503
+ }
242
504
  const outputPrefix = loaded.config.outputDir.replace(/\\/g, "/").replace(/\/$/, "") + "/";
505
+ const previousPrefix = (metadata.generatedOutputDir ?? loaded.config.outputDir)
506
+ .replace(/\\/g, "/")
507
+ .replace(/\/$/, "") + "/";
243
508
  const generated = Object.fromEntries(plan.files.map((file) => [file.path.replace(/\\/g, "/"), file.content]));
509
+ for (const generatedPath of Object.keys(generated)) {
510
+ if (fs_.existsSync(join(project.root, generatedPath)) &&
511
+ !(generatedPath in metadata.managedFiles)) {
512
+ return {
513
+ ok: false,
514
+ reason: `generated path collides with an unowned user file: ${generatedPath}`,
515
+ };
516
+ }
517
+ }
244
518
  const nextManaged = {};
245
519
  for (const [managedPath, hash] of Object.entries(metadata.managedFiles))
246
- if (!managedPath.startsWith(outputPrefix))
520
+ if (!managedPath.startsWith(outputPrefix) && !managedPath.startsWith(previousPrefix))
247
521
  nextManaged[managedPath] = hash;
248
522
  for (const [path, content] of Object.entries(generated))
249
523
  nextManaged[path] = sha256Bytes(content);
250
- const nextMetadata = { ...metadata, managedFiles: nextManaged };
524
+ const nextMetadata = {
525
+ ...metadata,
526
+ managedFiles: nextManaged,
527
+ generatedOutputDir: loaded.config.outputDir,
528
+ };
251
529
  const writes = { ...generated };
252
530
  for (const file of sideFiles) {
253
531
  const absolute = resolve(file.path);
@@ -257,8 +535,17 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
257
535
  writes[rel] = file.content;
258
536
  }
259
537
  writes[".moeicons/install-metadata.json"] = serializeInstallMetadata(nextMetadata);
260
- const stale = Object.keys(metadata.managedFiles).filter((path) => path.startsWith(outputPrefix) && !(path in generated));
261
- withProjectLockSync(project.root, "reload", () => executeManagedReconcile(project.root, writes, stale, fs_));
538
+ const stale = Object.keys(metadata.managedFiles).filter((path) => (path.startsWith(outputPrefix) || path.startsWith(previousPrefix)) &&
539
+ !(path in generated));
540
+ const expectedSha256 = {
541
+ ...metadata.managedFiles,
542
+ ...expectedSideFiles,
543
+ ".moeicons/install-metadata.json": sha256Bytes(fs_.readFileSync(metadataPath)),
544
+ };
545
+ for (const path of Object.keys(generated))
546
+ if (!(path in expectedSha256))
547
+ expectedSha256[path] = undefined;
548
+ executeManagedReconcile(project.root, writes, stale, fs_, { expectedSha256 });
262
549
  return {
263
550
  ok: true,
264
551
  files: plan.files.map((file) => file.path),
@@ -266,18 +553,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
266
553
  ? { warnings: [...loaded.warnings, ...notes] }
267
554
  : {}),
268
555
  };
269
- }
270
- executeGeneratedFilesDir(plan.files, project.root, loaded.config.outputDir, fs_);
271
- for (const file of sideFiles) {
272
- fs_.writeFileSync(file.path, file.content);
273
- }
274
- return {
275
- ok: true,
276
- files: plan.files.map((file) => file.path),
277
- ...(loaded.warnings.length > 0 || notes.length > 0
278
- ? { warnings: [...loaded.warnings, ...notes] }
279
- : {}),
280
- };
556
+ });
281
557
  }
282
558
  catch (error) {
283
559
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
@@ -0,0 +1,8 @@
1
+ import { type IconCatalog } from "../catalog/catalog.js";
2
+ import type { MoeiconsConfigFile } from "../project/config.js";
3
+ /** A theme owns a variant only when it is selected and shipped by the catalog. */
4
+ export declare function themeHasIcon(config: MoeiconsConfigFile, catalog: IconCatalog, theme: string, iconId: string): boolean;
5
+ /** Stable fallback: requested theme, default theme, then theme keys in ASCII order.
6
+ * Unregistered icons and an empty configured variant set never resolve.
7
+ */
8
+ export declare function resolveIconTheme(config: MoeiconsConfigFile, catalog: IconCatalog, requested: string, iconId: string): string | undefined;
@@ -0,0 +1,19 @@
1
+ import { findCatalogIcon } from "../catalog/catalog.js";
2
+ /** A theme owns a variant only when it is selected and shipped by the catalog. */
3
+ export function themeHasIcon(config, catalog, theme, iconId) {
4
+ const entry = config.themes[theme];
5
+ return Boolean(entry && config.icons.includes(iconId) &&
6
+ (entry.icons === undefined || entry.icons.includes(iconId)) &&
7
+ findCatalogIcon(iconId, catalog)?.availableIn.includes(entry.styleGroup));
8
+ }
9
+ /** Stable fallback: requested theme, default theme, then theme keys in ASCII order.
10
+ * Unregistered icons and an empty configured variant set never resolve.
11
+ */
12
+ export function resolveIconTheme(config, catalog, requested, iconId) {
13
+ if (themeHasIcon(config, catalog, requested, iconId))
14
+ return requested;
15
+ if (config.missingIconPolicy !== "fallback")
16
+ return undefined;
17
+ const candidates = [config.defaultTheme, ...Object.keys(config.themes).sort()];
18
+ return candidates.find((theme) => themeHasIcon(config, catalog, theme, iconId));
19
+ }
@@ -15,7 +15,12 @@ export type InstallResult = {
15
15
  readonly artifactVersion: string;
16
16
  readonly descriptorSha256: string;
17
17
  readonly catalogSha256: string;
18
+ readonly metadataSha256: string;
18
19
  readonly cacheHit: boolean;
20
+ readonly downloadMode?: "icons" | "full";
21
+ readonly downloadNotes?: readonly string[];
22
+ readonly networkBytes?: number;
23
+ readonly selectedFiles?: number;
19
24
  } | {
20
25
  readonly ok: false;
21
26
  readonly reason: "no-project";
@@ -39,6 +44,10 @@ export type InstallResult = {
39
44
  readonly ok: false;
40
45
  readonly reason: "offline-no-cache";
41
46
  readonly message: string;
47
+ } | {
48
+ readonly ok: false;
49
+ readonly reason: "disk-full";
50
+ readonly message: string;
42
51
  } | {
43
52
  readonly ok: false;
44
53
  readonly reason: "validation";
@@ -52,6 +61,11 @@ export interface InstallUseCaseDeps {
52
61
  readonly fs: TransactionalFs;
53
62
  readonly download: Omit<FreeDownloadIo, "signal">;
54
63
  }
64
+ /**
65
+ * P0-3: the project `types.ts` must re-export a type that actually exists in the
66
+ * installed `moe-icons` package. Assets have no component type, so it is empty.
67
+ */
68
+ export declare function typesReexport(tier: "free" | "pro", target: Target, outputDir?: string): string;
55
69
  /**
56
70
  * Free install: resolve the catalog sourceVersion tag, download/verify the
57
71
  * GitHub Release (or a local release fixture), verify the selected target's