@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,10 +1,30 @@
1
+ import { EMPTY_DOWNLOAD_SELECTION_MESSAGE } from "./selected-resources.js";
1
2
  import { createInstallPlan, executeInstallPlan } from "../project/install.js";
2
3
  import { detectProject } from "../project/detect.js";
3
- import { readMoeiconsConfig } from "../project/config.js";
4
+ import { readMoeiconsConfig, loadConfigDocument } from "../project/config.js";
5
+ import { CliError, isCliError } from "../errors/index.js";
4
6
  import { bundledSourceVersion, downloadFreeRelease } from "./free-download.js";
5
7
  import { serializeInstallMetadata, sha256Bytes } from "../project/install-metadata.js";
6
8
  import { withProjectLock } from "../project/project-lock.js";
7
- import { selectTargetSubtree } from "./target-subtree.js";
9
+ import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
10
+ import { parseCatalog } from "../catalog/catalog.js";
11
+ import { posix } from "node:path";
12
+ /**
13
+ * P0-3: the project `types.ts` must re-export a type that actually exists in the
14
+ * installed `moe-icons` package. Assets have no component type, so it is empty.
15
+ */
16
+ export function typesReexport(tier, target, outputDir = "src/moeicons") {
17
+ void tier;
18
+ const from = posix.relative(outputDir.replace(/\\/g, "/"), `.moeicons/artifact/${target}/types`);
19
+ const specifier = from.startsWith(".") ? from : `./${from}`;
20
+ if (target === "react")
21
+ return `export type { ReactIconProps } from "${specifier}";\n`;
22
+ if (target === "vue")
23
+ return `export type { VueIconProps } from "${specifier}";\n`;
24
+ if (target === "vanilla")
25
+ return `export type { VanillaIconOptions } from "${specifier}";\n`;
26
+ return "export {};\n";
27
+ }
8
28
  function normalizeGroup(group) {
9
29
  if (group === undefined || group === "free")
10
30
  return "free";
@@ -25,7 +45,14 @@ export async function runInstallUseCase(context, deps, options) {
25
45
  return { ok: false, reason: "no-project" };
26
46
  const groupArg = options.group;
27
47
  if (groupArg !== undefined && groupArg !== "free" && groupArg !== "pro" && groupArg !== "ent") {
28
- return { ok: false, reason: "validation", message: `unknown install group: ${groupArg}` };
48
+ // AUD-CL-01: single style-group install (`moeicons install <styleGroupId>`)
49
+ // is not supported. Fail closed with explicit guidance instead of silently
50
+ // treating an unknown group as Free.
51
+ return {
52
+ ok: false,
53
+ reason: "validation",
54
+ message: `unknown install group "${groupArg}"; single style-group install is not supported — run "moeicons install free" or "moeicons install pro"`,
55
+ };
29
56
  }
30
57
  const group = normalizeGroup(groupArg);
31
58
  if (group === "pro") {
@@ -35,16 +62,41 @@ export async function runInstallUseCase(context, deps, options) {
35
62
  message: "pro install requires the authenticated pro flow",
36
63
  };
37
64
  }
38
- const config = readMoeiconsConfig(project.root);
65
+ const document = loadConfigDocument(project.root);
66
+ const plannedConfigDocument = JSON.stringify(document);
67
+ const config = readMoeiconsConfig(project.root, undefined, { lenientCatalog: true });
39
68
  if (config.kind === "invalid" || config.kind === "unsupported") {
40
69
  return {
41
70
  ok: false,
42
71
  reason: "validation",
43
- message: config.kind === "invalid" ? config.message : `unsupported config schema version ${config.version}`,
72
+ message: config.kind === "invalid"
73
+ ? config.message
74
+ : `unsupported config schema version ${config.version}`,
44
75
  };
45
76
  }
77
+ if (config.kind === "ok" && config.config.tier !== "free")
78
+ return {
79
+ ok: false,
80
+ reason: "validation",
81
+ message: 'config.tier is pro; run "moeicons install pro" or change tier to free before downloading',
82
+ };
83
+ if (config.kind === "ok" && config.config.icons.length === 0)
84
+ return { ok: false, reason: "validation", message: EMPTY_DOWNLOAD_SELECTION_MESSAGE };
46
85
  const target = options.target ?? (config.kind === "ok" ? config.config.target : "react");
47
- const downloaded = await downloadFreeRelease({ ...deps.download, signal: context.signal }, options.sourceVersion ?? bundledSourceVersion());
86
+ if (config.kind === "ok" && config.config.target !== target) {
87
+ return {
88
+ ok: false,
89
+ reason: "validation",
90
+ message: `config target is "${config.config.target}" but install target is "${target}"; set target to "${target}" in moeicons.config before installing`,
91
+ };
92
+ }
93
+ const downloaded = await downloadFreeRelease({ ...deps.download, signal: context.signal }, options.sourceVersion ?? bundledSourceVersion(), config.kind === "ok"
94
+ ? {
95
+ config: config.config,
96
+ document,
97
+ onPlan: (message) => context.ui.note(message, context.signal),
98
+ }
99
+ : undefined);
48
100
  if (!downloaded.ok) {
49
101
  return downloaded.reason === "cancelled"
50
102
  ? { ok: false, reason: "cancelled", message: downloaded.message }
@@ -59,21 +111,52 @@ export async function runInstallUseCase(context, deps, options) {
59
111
  message: "downloaded release identity changed after version check; retry the update",
60
112
  };
61
113
  }
62
- const subtree = selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor.free, target);
114
+ const selectedTarget = downloaded.selected
115
+ ? Object.fromEntries(Object.entries(downloaded.selected.files)
116
+ .filter(([path]) => path.startsWith(`${target}/`))
117
+ .map(([path, bytes]) => [path.slice(target.length + 1), bytes]))
118
+ : undefined;
119
+ const subtree = selectedTarget
120
+ ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
121
+ : selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor.free, target);
63
122
  if (!subtree.ok) {
64
123
  return subtree.reason === "checksum-mismatch"
65
124
  ? { ok: false, reason: "checksum-mismatch", message: subtree.message }
66
125
  : { ok: false, reason: "validation", message: subtree.message };
67
126
  }
127
+ let installedFiles;
128
+ try {
129
+ installedFiles = downloaded.selected
130
+ ? subtree.files
131
+ : configuredComponentFiles(subtree.files, target, config.kind === "ok" ? config.config : undefined, parseCatalog(JSON.parse(downloaded.catalogJson)));
132
+ }
133
+ catch (error) {
134
+ return {
135
+ ok: false,
136
+ reason: "validation",
137
+ message: error instanceof Error ? error.message : String(error),
138
+ };
139
+ }
68
140
  const catalogJson = downloaded.catalogJson;
141
+ const outputDir = config.kind === "ok"
142
+ ? config.config.outputDir.replace(/\\/g, "/").replace(/\/$/, "")
143
+ : "src/moeicons";
69
144
  const files = {
70
145
  ".moeicons/catalog.json": catalogJson,
71
- "src/moeicons/types.ts": `export type { ReactIconProps } from "moe-icons";\n`,
72
- "src/moeicons/.moeicons-free.marker": "free\n",
146
+ ".moeicons/manifest.json": downloaded.manifestJson,
147
+ ".moeicons/MANUAL.md": downloaded.manualMd,
148
+ ".moeicons/artifact/package.json": '{"private":true,"type":"module","sideEffects":false}\n',
149
+ [`${outputDir}/types.ts`]: typesReexport("free", target, outputDir),
150
+ [`${outputDir}/.moeicons-free.marker`]: "free\n",
73
151
  };
74
- for (const [rel, bytes] of Object.entries(subtree.files)) {
152
+ for (const [rel, bytes] of Object.entries(installedFiles)) {
75
153
  files[`.moeicons/artifact/${target}/${rel}`] = bytes;
76
154
  }
155
+ if (downloaded.selected) {
156
+ files[".moeicons/resource-index.json.gz"] = downloaded.selected.indexBytes;
157
+ for (const [rel, bytes] of Object.entries(downloaded.selected.files))
158
+ files[`.moeicons/artifact/${rel}`] = bytes;
159
+ }
77
160
  const managedFiles = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, sha256Bytes(content)]));
78
161
  files[".moeicons/install-metadata.json"] = serializeInstallMetadata({
79
162
  schemaVersion: 1,
@@ -85,18 +168,36 @@ export async function runInstallUseCase(context, deps, options) {
85
168
  artifactSha256: downloaded.descriptor.free.sha256,
86
169
  installedAt: context.now().toISOString(),
87
170
  managedFiles,
171
+ ...(downloaded.selected
172
+ ? {
173
+ delivery: {
174
+ mode: "icons",
175
+ indexSha256: downloaded.selected.refs.index.sha256,
176
+ bundleSha256: downloaded.selected.refs.bundle.sha256,
177
+ },
178
+ }
179
+ : {}),
88
180
  targetSha256: subtree.sha256,
89
181
  targetFileCount: subtree.fileCount,
90
182
  targetByteCount: subtree.byteCount,
183
+ // Local-test candidates record the declared model so a later generate can
184
+ // verify the install state (mirrors the Pro install path).
185
+ ...(downloaded.descriptor.channel === "local-test"
186
+ ? { channel: "local-test", publishable: false }
187
+ : {}),
91
188
  });
92
189
  const plan = createInstallPlan(project.root, files);
93
190
  try {
94
- await withProjectLock(project.root, "install", () => executeInstallPlan(plan, deps.fs));
191
+ await withProjectLock(project.root, "install", () => {
192
+ if (JSON.stringify(loadConfigDocument(project.root)) !== plannedConfigDocument)
193
+ throw new CliError("VALIDATION_ERROR", "config changed while preparing install; retry");
194
+ executeInstallPlan(plan, deps.fs);
195
+ });
95
196
  }
96
197
  catch (error) {
97
198
  return {
98
199
  ok: false,
99
- reason: "write-failed",
200
+ reason: isCliError(error) && error.code === "VALIDATION_ERROR" ? "validation" : "write-failed",
100
201
  message: error instanceof Error ? error.message : String(error),
101
202
  };
102
203
  }
@@ -106,12 +207,30 @@ export async function runInstallUseCase(context, deps, options) {
106
207
  packageManager: project.packageManager,
107
208
  group: "free",
108
209
  target,
109
- artifactBytes: downloaded.artifactBytes.byteLength,
210
+ artifactBytes: downloaded.selected?.payloadBytes ?? downloaded.artifactBytes.byteLength,
211
+ downloadMode: downloaded.selected ? "icons" : "full",
212
+ ...(downloaded.selected
213
+ ? {
214
+ networkBytes: downloaded.selected.networkBytes,
215
+ selectedFiles: Object.keys(downloaded.selected.files).length,
216
+ }
217
+ : {}),
218
+ downloadNotes: downloaded.selected?.fallbacks ??
219
+ (config.kind === "ok" && config.config.downloadMode !== "full"
220
+ ? [
221
+ "This release does not support selected downloads; used the verified full archive. Set downloadMode=icons to require selected downloads.",
222
+ ]
223
+ : config.kind === "ok"
224
+ ? []
225
+ : [
226
+ "No config was found; installed the full archive. Run moeicons init, select icons/themes, then reinstall for selected downloads.",
227
+ ]),
110
228
  planItems: plan.items.length,
111
229
  config: config.kind,
112
230
  artifactVersion: downloaded.descriptor.fullVersion,
113
231
  descriptorSha256: downloaded.descriptorSha256,
114
232
  catalogSha256: downloaded.descriptor.catalog.sha256,
233
+ metadataSha256: downloaded.metadataSha256,
115
234
  cacheHit: downloaded.cacheHit,
116
235
  };
117
236
  }
@@ -4,7 +4,7 @@ import type { CommandContext } from "./context.js";
4
4
  import { type FreeDownloadIo } from "./free-download.js";
5
5
  export interface LibraryUpdateDeps {
6
6
  readonly fs: TransactionalFsWithCopy;
7
- readonly free: Omit<FreeDownloadIo, "signal">;
7
+ readonly free?: Omit<FreeDownloadIo, "signal">;
8
8
  readonly auth: AuthUseCaseDependencies;
9
9
  readonly fetch?: typeof fetch;
10
10
  readonly allowedProHosts?: readonly string[];
@@ -21,4 +21,8 @@ export declare function runLibraryUpdateUseCase(context: CommandContext, deps: L
21
21
  readonly projectRoot: string;
22
22
  readonly artifactVersion: string;
23
23
  readonly files: readonly string[];
24
+ readonly downloadMode: "icons" | "full";
25
+ readonly downloadNotes: readonly string[];
26
+ readonly networkBytes?: number;
27
+ readonly selectedFiles?: number;
24
28
  }>;
@@ -1,62 +1,97 @@
1
+ import { assertDownloadSelection } from "./selected-resources.js";
1
2
  import { join, relative, resolve } from "node:path";
2
3
  import { homedir } from "node:os";
3
4
  import { parseCatalog } from "../catalog/catalog.js";
4
5
  import { CliError } from "../errors/index.js";
5
6
  import { planGeneratedFiles } from "../generator/generate.js";
6
- import { readMoeiconsConfig } from "../project/config.js";
7
+ import { readMoeiconsConfig, loadConfigDocument, validateConfigDocument, } from "../project/config.js";
7
8
  import { detectProject } from "../project/detect.js";
8
9
  import { executeManagedReconcile } from "../project/install.js";
9
10
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
10
11
  import { withProjectLock } from "../project/project-lock.js";
11
12
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
12
- import { extractTarGz } from "../project/tar-gz.js";
13
- import { artifactCachePath, downloadFreeRelease } from "./free-download.js";
13
+ import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
14
+ import { artifactCachePath, downloadFreeRelease, metadataCachePath, } from "./free-download.js";
14
15
  import { downloadProArtifact } from "./pro-download.js";
15
- import { selectTargetSubtree } from "./target-subtree.js";
16
+ import { PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint } from "./pro-download.js";
17
+ import { runAccessTokenUseCase } from "./auth.js";
18
+ import { resolveBitmapTuples, resolveConfiguredBitmapShards } from "./bitmap-shard-resolver.js";
19
+ import { mergeBitmapShardCacheManifest, readBitmapShardCacheManifest, writeBitmapShardCacheManifest, } from "./bitmap-shard-cache.js";
20
+ import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
16
21
  export async function runLibraryUpdateUseCase(context, deps, expected) {
17
22
  const project = detectProject(context.cwd);
18
23
  if (!project)
19
24
  throw new CliError("VALIDATION_ERROR", "no package.json found in the current directory or parents");
25
+ const document = loadConfigDocument(project.root);
26
+ const bootstrap = validateConfigDocument(document, undefined, { lenientCatalog: true });
27
+ if (bootstrap.kind !== "ok" || bootstrap.config.tier !== expected.tier)
28
+ throw new CliError("VALIDATION_ERROR", bootstrap.kind === "invalid"
29
+ ? bootstrap.message
30
+ : `config.tier must match ${expected.tier}; edit config before updating`);
31
+ assertDownloadSelection(bootstrap.config);
32
+ let selected;
20
33
  let catalogJson;
21
34
  let archiveBytes;
22
35
  let artifactSha256;
23
36
  let catalogSha256;
37
+ let manifestJson;
38
+ let manualMd;
24
39
  let tierSource;
25
40
  if (expected.tier === "free") {
41
+ if (!deps.free)
42
+ throw new CliError("VALIDATION_ERROR", "free release download dependencies are missing");
26
43
  const downloaded = await downloadFreeRelease({
27
44
  ...deps.free,
28
45
  signal: context.signal,
29
46
  ...(deps.onProgress ? { onProgress: deps.onProgress } : {}),
30
- }, expected.version);
47
+ }, expected.version, {
48
+ config: bootstrap.config,
49
+ document,
50
+ onPlan: (message) => context.ui.note(message, context.signal),
51
+ });
31
52
  if (!downloaded.ok)
32
53
  throw new CliError(downloaded.reason === "cancelled" ? "CANCELLED" : "VALIDATION_ERROR", downloaded.message);
33
54
  if (downloaded.descriptorSha256 !== expected.descriptorSha256 ||
34
55
  downloaded.descriptor.fullVersion !== expected.version) {
35
56
  throw new CliError("VALIDATION_ERROR", "downloaded release identity changed after version check; retry the update");
36
57
  }
58
+ selected = downloaded.selected;
37
59
  catalogJson = downloaded.catalogJson;
38
60
  archiveBytes = downloaded.artifactBytes;
39
61
  artifactSha256 = downloaded.descriptor.free.sha256;
40
62
  catalogSha256 = downloaded.descriptor.catalog.sha256;
63
+ manifestJson = downloaded.manifestJson;
64
+ manualMd = downloaded.manualMd;
41
65
  tierSource = downloaded.descriptor.free;
42
66
  }
43
67
  else {
44
68
  const downloaded = await downloadProArtifact(context, deps.auth, expected, {
69
+ selection: { config: bootstrap.config, document },
45
70
  ...(deps.fetch ? { fetch: deps.fetch } : {}),
46
71
  ...(deps.allowedProHosts ? { allowedHosts: deps.allowedProHosts } : {}),
47
72
  ...(deps.onProgress ? { onProgress: deps.onProgress } : {}),
48
73
  });
74
+ selected = downloaded.selected;
49
75
  catalogJson = downloaded.catalogJson;
50
76
  archiveBytes = downloaded.artifactBytes;
51
77
  artifactSha256 = downloaded.descriptor.sha256;
52
78
  catalogSha256 = downloaded.descriptor.catalogSha256;
79
+ manifestJson = downloaded.manifestJson;
80
+ manualMd = downloaded.manualMd;
53
81
  tierSource = downloaded.descriptor;
54
82
  const cacheDir = context.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache");
55
83
  const cached = artifactCachePath(cacheDir, downloaded.descriptor.version, downloaded.descriptor.sha256);
56
- if (!deps.fs.existsSync(cached)) {
84
+ if (!selected && !deps.fs.existsSync(cached)) {
57
85
  deps.fs.mkdirSync(join(cached, ".."), { recursive: true });
58
86
  deps.fs.writeFileSync(cached, downloaded.artifactBytes);
59
87
  }
88
+ if (downloaded.metadataBytes && downloaded.descriptor.metadata) {
89
+ const metaCached = metadataCachePath(cacheDir, downloaded.descriptor.version, downloaded.descriptor.metadata.sha256);
90
+ if (!deps.fs.existsSync(metaCached)) {
91
+ deps.fs.mkdirSync(join(metaCached, ".."), { recursive: true });
92
+ deps.fs.writeFileSync(metaCached, downloaded.metadataBytes);
93
+ }
94
+ }
60
95
  }
61
96
  let candidateCatalog;
62
97
  try {
@@ -69,49 +104,127 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
69
104
  sha256Bytes(catalogJson) !== catalogSha256) {
70
105
  throw new CliError("VALIDATION_ERROR", "candidate catalog identity does not match the selected release");
71
106
  }
72
- const loaded = readMoeiconsConfig(project.root, candidateCatalog);
107
+ const loaded = validateConfigDocument(document, candidateCatalog);
73
108
  if (loaded.kind !== "ok" || loaded.config.tier !== expected.tier)
74
109
  throw new CliError("VALIDATION_ERROR", `config is invalid for the ${expected.tier} candidate`);
75
- const unpacked = extractTarGz(archiveBytes, {
76
- maxEntries: 20_000,
77
- maxExpandedBytes: 64 * 1024 * 1024,
78
- });
110
+ const plannedConfig = JSON.stringify(loaded.config);
111
+ const installMetadataPath = join(project.root, ".moeicons", "install-metadata.json");
112
+ const plannedMetadata = deps.fs.existsSync(installMetadataPath)
113
+ ? deps.fs.readFileSync(installMetadataPath, "utf8")
114
+ : undefined;
115
+ const unpacked = selected
116
+ ? { files: selected.files, errors: [] }
117
+ : extractTarGz(archiveBytes, {
118
+ maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
119
+ maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
120
+ });
79
121
  if (unpacked.errors.length)
80
122
  throw new CliError("VALIDATION_ERROR", unpacked.errors[0] ?? "invalid artifact");
81
123
  const target = loaded.config.target;
82
- const subtree = selectTargetSubtree(archiveBytes, tierSource, target);
124
+ const selectedTarget = selected
125
+ ? Object.fromEntries(Object.entries(selected.files)
126
+ .filter(([path]) => path.startsWith(`${target}/`))
127
+ .map(([path, bytes]) => [path.slice(target.length + 1), bytes]))
128
+ : undefined;
129
+ const subtree = selectedTarget
130
+ ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
131
+ : selectTargetSubtree(archiveBytes, tierSource, target);
83
132
  if (!subtree.ok)
84
133
  throw new CliError("VALIDATION_ERROR", subtree.message);
134
+ const archiveFiles = { ...unpacked.files };
135
+ let bitmapPins;
136
+ let bitmapShardSetSha256;
137
+ const tuples = resolveBitmapTuples(loaded.config, candidateCatalog);
138
+ if (!tuples.ok)
139
+ throw new CliError("VALIDATION_ERROR", tuples.errors.join("; "));
140
+ if (!selected && tuples.tuples.length > 0 && expected.tier === "pro") {
141
+ const cacheDir = context.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache");
142
+ const cacheIo = {
143
+ mkdirSync: (path) => deps.fs.mkdirSync(path, { recursive: true }),
144
+ writeFileSync: deps.fs.writeFileSync,
145
+ renameSync: deps.fs.renameSync,
146
+ existsSync: deps.fs.existsSync,
147
+ rmSync: deps.fs.rmSync,
148
+ readFileSync: deps.fs.readFileSync,
149
+ readdirSync: deps.fs.readdirSync,
150
+ };
151
+ const cacheManifest = readBitmapShardCacheManifest(cacheDir, cacheIo);
152
+ const metadataPath = join(project.root, ".moeicons", "install-metadata.json");
153
+ const old = deps.fs.existsSync(metadataPath)
154
+ ? parseInstallMetadata(deps.fs.readFileSync(metadataPath, "utf8"))
155
+ : undefined;
156
+ const existingPins = mergeBitmapShardCacheManifest(cacheManifest, old?.bitmapShards ?? []).filter((pin) => pin.resourceVersion === expected.version);
157
+ const accessToken = await runAccessTokenUseCase(context, deps.auth);
158
+ const { loopbackHost } = resolveProDescriptorEndpoint(context.env);
159
+ const allowedHosts = deps.allowedProHosts ??
160
+ (loopbackHost ? [...PRO_DOWNLOAD_HOSTS, loopbackHost] : PRO_DOWNLOAD_HOSTS);
161
+ const shards = await resolveConfiguredBitmapShards({
162
+ config: loaded.config,
163
+ catalog: candidateCatalog,
164
+ version: expected.version,
165
+ descriptorSha256: expected.descriptorSha256,
166
+ cacheDir,
167
+ io: cacheIo,
168
+ accessToken,
169
+ allowedHosts,
170
+ existingPins,
171
+ env: context.env,
172
+ now: context.now().getTime(),
173
+ signal: context.signal,
174
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
175
+ });
176
+ Object.assign(archiveFiles, shards.files);
177
+ bitmapPins = shards.pins;
178
+ bitmapShardSetSha256 = shards.bitmapShardSetSha256;
179
+ writeBitmapShardCacheManifest(cacheDir, cacheIo, mergeBitmapShardCacheManifest(cacheManifest, shards.pins), context.now().getTime());
180
+ }
85
181
  const generated = planGeneratedFiles(loaded.config, loaded.config.outputDir, {
86
- archiveFiles: unpacked.files,
182
+ archiveFiles,
87
183
  catalog: candidateCatalog,
88
184
  });
89
185
  if (!generated.ok)
90
186
  throw new CliError("VALIDATION_ERROR", generated.errors.join("; "));
187
+ const installedFiles = selected
188
+ ? subtree.files
189
+ : configuredComponentFiles(subtree.files, target, loaded.config, candidateCatalog);
91
190
  const writes = {
92
191
  ".moeicons/catalog.json": catalogJson,
192
+ ".moeicons/manifest.json": manifestJson,
193
+ ".moeicons/MANUAL.md": manualMd,
194
+ ".moeicons/artifact/package.json": '{"private":true,"type":"module","sideEffects":false}\n',
93
195
  [`${loaded.config.outputDir.replace(/\\/g, "/").replace(/\/$/, "")}/.moeicons-${expected.tier}.marker`]: `${expected.tier}\n`,
94
196
  };
95
197
  for (const file of generated.files)
96
198
  writes[file.path.replace(/\\/g, "/")] = file.content;
97
- for (const [rel, bytes] of Object.entries(subtree.files)) {
199
+ for (const [rel, bytes] of Object.entries(installedFiles)) {
98
200
  writes[`.moeicons/artifact/${target}/${rel}`] = bytes;
99
201
  }
202
+ if (selected) {
203
+ writes[".moeicons/resource-index.json.gz"] = selected.indexBytes;
204
+ for (const [rel, bytes] of Object.entries(selected.files))
205
+ writes[`.moeicons/artifact/${rel}`] = bytes;
206
+ }
100
207
  const tailwindPlan = planTailwindIntegration(project.root, loaded.config.outputDir, {
101
208
  noTailwind: false,
102
209
  target,
103
210
  });
211
+ const expectedSideFiles = {};
104
212
  for (const side of tailwindPlan.files) {
105
213
  const rel = relative(project.root, resolve(side.path)).replace(/\\/g, "/");
106
214
  if (!rel || rel === ".." || rel.startsWith("../"))
107
215
  throw new CliError("VALIDATION_ERROR", `side file escapes project: ${side.path}`);
108
216
  writes[rel] = side.content;
217
+ expectedSideFiles[rel] = deps.fs.existsSync(side.path)
218
+ ? sha256Bytes(deps.fs.readFileSync(side.path))
219
+ : undefined;
109
220
  }
110
221
  const pkgPath = join(project.root, "package.json");
111
222
  if (deps.fs.existsSync(pkgPath)) {
223
+ const pkgSource = deps.fs.readFileSync(pkgPath, "utf8");
224
+ expectedSideFiles["package.json"] = sha256Bytes(pkgSource);
112
225
  const dependencyPlan = target === "react" || target === "vue"
113
- ? ensureClassMergeDependencies(deps.fs.readFileSync(pkgPath, "utf8"))
114
- : { nextSource: deps.fs.readFileSync(pkgPath, "utf8"), changed: false, notes: [] };
226
+ ? ensureClassMergeDependencies(pkgSource)
227
+ : { nextSource: pkgSource, changed: false, notes: [] };
115
228
  if (dependencyPlan.changed)
116
229
  writes["package.json"] = dependencyPlan.nextSource;
117
230
  }
@@ -132,13 +245,33 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
132
245
  catalogSha256,
133
246
  installedAt: context.now().toISOString(),
134
247
  managedFiles,
248
+ ...(selected
249
+ ? {
250
+ delivery: {
251
+ mode: "icons",
252
+ indexSha256: selected.refs.index.sha256,
253
+ bundleSha256: selected.refs.bundle.sha256,
254
+ },
255
+ }
256
+ : {}),
257
+ generatedOutputDir: loaded.config.outputDir,
135
258
  targetSha256: subtree.sha256,
136
259
  targetFileCount: subtree.fileCount,
137
260
  targetByteCount: subtree.byteCount,
261
+ ...(bitmapPins && bitmapShardSetSha256
262
+ ? { bitmapShards: bitmapPins, bitmapShardSetSha256 }
263
+ : {}),
138
264
  };
139
265
  writes[".moeicons/install-metadata.json"] = serializeInstallMetadata(nextMetadata);
140
266
  await withProjectLock(project.root, "update", () => {
267
+ const currentConfig = readMoeiconsConfig(project.root, candidateCatalog);
268
+ if (currentConfig.kind !== "ok" || JSON.stringify(currentConfig.config) !== plannedConfig)
269
+ throw new CliError("VALIDATION_ERROR", "config changed while preparing update; retry");
141
270
  const metadataPath = join(project.root, ".moeicons", "install-metadata.json");
271
+ if (!plannedMetadata ||
272
+ !deps.fs.existsSync(metadataPath) ||
273
+ deps.fs.readFileSync(metadataPath, "utf8") !== plannedMetadata)
274
+ throw new CliError("VALIDATION_ERROR", "installation changed while preparing update; retry");
142
275
  if (!deps.fs.existsSync(metadataPath))
143
276
  throw new CliError("VALIDATION_ERROR", "managed install metadata is missing");
144
277
  const old = parseInstallMetadata(deps.fs.readFileSync(metadataPath, "utf8"));
@@ -151,11 +284,37 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
151
284
  throw new CliError("VALIDATION_ERROR", `managed file was modified or removed: ${path}`);
152
285
  }
153
286
  }
154
- executeManagedReconcile(project.root, writes, Object.keys(old.managedFiles).filter((path) => !(path in managedFiles)), deps.fs);
287
+ for (const path of Object.keys(managedFiles)) {
288
+ if (deps.fs.existsSync(join(project.root, path)) && !(path in old.managedFiles)) {
289
+ throw new CliError("VALIDATION_ERROR", `update path collides with an unowned user file: ${path}`);
290
+ }
291
+ }
292
+ executeManagedReconcile(project.root, writes, Object.keys(old.managedFiles).filter((path) => !(path in managedFiles)), deps.fs, {
293
+ expectedSha256: {
294
+ ...Object.fromEntries([...Object.keys(writes), ...Object.keys(old.managedFiles)].map((path) => [
295
+ path,
296
+ old.managedFiles[path] ??
297
+ (path === ".moeicons/install-metadata.json"
298
+ ? sha256Bytes(plannedMetadata)
299
+ : undefined),
300
+ ])),
301
+ ...expectedSideFiles,
302
+ },
303
+ });
155
304
  });
156
305
  return {
157
306
  projectRoot: project.root,
158
307
  artifactVersion: expected.version,
159
308
  files: generated.files.map((file) => file.path),
309
+ downloadMode: selected ? "icons" : "full",
310
+ downloadNotes: selected?.fallbacks ??
311
+ (bootstrap.config.downloadMode === "full"
312
+ ? []
313
+ : [
314
+ "Release does not advertise selected downloads; auto used the verified full archive. Set downloadMode=icons to require selected downloads.",
315
+ ]),
316
+ ...(selected
317
+ ? { networkBytes: selected.networkBytes, selectedFiles: Object.keys(selected.files).length }
318
+ : {}),
160
319
  };
161
320
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A-1b (DEC-A1): a strictly gated seam that lets the packed CLI consume a
3
+ * declared local-test candidate (`X.Y.Z-test`, channel `local-test`,
4
+ * `publishable: false`) through the Pro flow, without touching the production
5
+ * version parsers. `allowLocalTest` is true only when ALL hold:
6
+ * - MOEICONS_ENV=local
7
+ * - the resolved versions endpoint is loopback HTTP
8
+ * - the resolved Pro descriptor endpoint is loopback HTTP
9
+ * Any other combination fails closed, so a production endpoint returning
10
+ * `X.Y.Z-test` is still rejected.
11
+ */
12
+ export declare const LOCAL_TEST_ENV_VALUE = "local";
13
+ export declare function isLoopbackHttpUrl(value: string): boolean;
14
+ export declare function resolveLocalTestContext(env: Readonly<Record<string, string | undefined>>): {
15
+ readonly allowLocalTest: boolean;
16
+ };
17
+ /**
18
+ * Read-side local-test context for an already-installed project. The Pro network
19
+ * seam above is strict; parsing a *local* install metadata file may also be a
20
+ * Free fixture install driven by `MOEICONS_FREE_RELEASE_DIR` (the existing local
21
+ * release-directory seam). Both are local-only and never set on a production
22
+ * endpoint, so they share the read-side allowance.
23
+ */
24
+ export declare function allowLocalTestFromEnv(env: Readonly<Record<string, string | undefined>>): boolean;
@@ -0,0 +1,43 @@
1
+ import { resolveProDescriptorEndpoint } from "./pro-download.js";
2
+ import { resolveVersionsEndpoint } from "./version-service.js";
3
+ /**
4
+ * A-1b (DEC-A1): a strictly gated seam that lets the packed CLI consume a
5
+ * declared local-test candidate (`X.Y.Z-test`, channel `local-test`,
6
+ * `publishable: false`) through the Pro flow, without touching the production
7
+ * version parsers. `allowLocalTest` is true only when ALL hold:
8
+ * - MOEICONS_ENV=local
9
+ * - the resolved versions endpoint is loopback HTTP
10
+ * - the resolved Pro descriptor endpoint is loopback HTTP
11
+ * Any other combination fails closed, so a production endpoint returning
12
+ * `X.Y.Z-test` is still rejected.
13
+ */
14
+ export const LOCAL_TEST_ENV_VALUE = "local";
15
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
16
+ export function isLoopbackHttpUrl(value) {
17
+ try {
18
+ const url = new URL(value);
19
+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function resolveLocalTestContext(env) {
26
+ if (env.MOEICONS_ENV !== LOCAL_TEST_ENV_VALUE)
27
+ return { allowLocalTest: false };
28
+ if (!isLoopbackHttpUrl(resolveVersionsEndpoint(env)))
29
+ return { allowLocalTest: false };
30
+ if (!resolveProDescriptorEndpoint(env).allowLoopback)
31
+ return { allowLocalTest: false };
32
+ return { allowLocalTest: true };
33
+ }
34
+ /**
35
+ * Read-side local-test context for an already-installed project. The Pro network
36
+ * seam above is strict; parsing a *local* install metadata file may also be a
37
+ * Free fixture install driven by `MOEICONS_FREE_RELEASE_DIR` (the existing local
38
+ * release-directory seam). Both are local-only and never set on a production
39
+ * endpoint, so they share the read-side allowance.
40
+ */
41
+ export function allowLocalTestFromEnv(env) {
42
+ return resolveLocalTestContext(env).allowLocalTest || env.MOEICONS_FREE_RELEASE_DIR !== undefined;
43
+ }
@@ -0,0 +1,30 @@
1
+ import { type TransactionalFsWithCopy } from "../project/install.js";
2
+ import type { CommandContext } from "./context.js";
3
+ import type { AuthUseCaseDependencies } from "./auth.js";
4
+ import { type FreeDownloadIo } from "./free-download.js";
5
+ export interface MetadataSyncDeps {
6
+ readonly fs: TransactionalFsWithCopy;
7
+ readonly free: Omit<FreeDownloadIo, "signal">;
8
+ readonly auth: AuthUseCaseDependencies;
9
+ readonly fetch?: typeof fetch;
10
+ readonly allowedProHosts?: readonly string[];
11
+ readonly onProgress?: (event: {
12
+ readonly downloadedBytes: number;
13
+ readonly totalBytes?: number;
14
+ }) => void;
15
+ }
16
+ /**
17
+ * Metadata-only sync: downloads only the small metadata archive for the
18
+ * already-installed code version and reconciles `.moeicons/{MANUAL.md,
19
+ * catalog.json, manifest.json}`. Never downloads the code archive and never
20
+ * changes the installed code version.
21
+ */
22
+ export declare function runMetadataSyncUseCase(context: CommandContext, deps: MetadataSyncDeps, options?: {
23
+ readonly tier?: "free" | "pro";
24
+ }): Promise<{
25
+ readonly projectRoot: string;
26
+ readonly tier: "free" | "pro";
27
+ readonly artifactVersion: string;
28
+ readonly metadataSha256: string;
29
+ readonly files: readonly string[];
30
+ }>;