@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
package/dist/cli.js CHANGED
@@ -1,22 +1,36 @@
1
+ import { safeMcpValue } from "./mcp/server.js";
2
+ import { createProjectMcpServices } from "./mcp/services.js";
3
+ import { assertDownloadSelection } from "./core/selected-resources.js";
1
4
  import { parseArgs, HELP_TEXT } from "./commands/parser.js";
2
5
  import { CliError, isCliError, jsonErrorBody } from "./errors/index.js";
3
6
  import { detectProject } from "./project/detect.js";
4
- import { readMoeiconsConfig } from "./project/config.js";
5
- import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync, readdirSync, copyFileSync, } from "node:fs";
7
+ import { readMoeiconsConfig, loadConfigDocument, validateConfigDocument, } from "./project/config.js";
8
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, rmSync, readdirSync, copyFileSync, statfsSync, } from "node:fs";
6
9
  import { homedir } from "node:os";
7
10
  import { join } from "node:path";
8
- import { runInitUseCase } from "./core/init.js";
9
11
  import { runGenerateUseCase } from "./core/generate.js";
12
+ import { recoverManagedReconcile, safeManagedPath } from "./project/install.js";
13
+ import { withProjectLockSync } from "./project/project-lock.js";
10
14
  import { runInstallUseCase } from "./core/install.js";
11
- import { runWizardUseCase } from "./core/wizard.js";
15
+ import { runWizardUseCase, loginRecoveryChoices } from "./core/wizard.js";
12
16
  import { createCommandUi } from "./ui/create-ui.js";
13
- import { CLI_VERSION, MOEICONS_BANNER, renderBannerText, renderNoticeBox } from "./ui/banner.js";
14
- import { runAccountUseCase, runLoginUseCase, runLogoutUseCase, runSessionStatusUseCase, } from "./core/auth.js";
17
+ import { CLI_VERSION, MOEICONS_BANNER, renderNoticeBox, renderProjectNotice, renderWordmarkText, } from "./ui/banner.js";
18
+ import { createTheme, isThemeEnabled } from "./ui/theme.js";
19
+ import { renderHistoryBar } from "./tui/components.js";
20
+ import { describeAuthEnvironment, runAccountUseCase, runLoginUseCase, runLogoutUseCase, runRemoteAccountUseCase, runSessionStatusUseCase, } from "./core/auth.js";
15
21
  import { formatLibraryVersionStatus, getLibraryVersionStatus } from "./core/manage.js";
16
22
  import { runCliUpdateCheck } from "./core/cli-update.js";
17
23
  import { fetchLibraryVersions } from "./core/version-service.js";
18
24
  import { runProInstallUseCase } from "./core/pro-install.js";
25
+ import { resolveLocalTestContext } from "./core/local-test-env.js";
19
26
  import { runLibraryUpdateUseCase } from "./core/library-update.js";
27
+ import { runMetadataSyncUseCase } from "./core/metadata-sync.js";
28
+ import { runBootstrapUseCase } from "./core/bootstrap.js";
29
+ import { fetchProDescriptor } from "./core/pro-download.js";
30
+ import { proResourceState, runProPredownloadUseCase, runProResourceWarmupUseCase, } from "./core/pro-resources.js";
31
+ import { formatBytes } from "./metadata/version.js";
32
+ import { isLocalTestVersion } from "./core/release-descriptor.js";
33
+ import { buildInitPlan, checkRequiresFix, collectSafeFixes, doctorJson, formatDoctorReport, formatPlannedDiffs, plannedDiffsJson, runDoctorApply, runDoctorDiagnose, } from "./core/doctor.js";
20
34
  function commandContext(runtime, flags) {
21
35
  const streams = runtime.readLine !== undefined && runtime.readKey !== undefined
22
36
  ? {
@@ -31,19 +45,64 @@ function commandContext(runtime, flags) {
31
45
  json: flags.json,
32
46
  yes: flags.yes,
33
47
  isTTY: runtime.isTTY(),
48
+ env: runtime.env,
34
49
  ...(streams ? { streams } : {}),
35
50
  }),
36
51
  cwd: runtime.cwd(),
37
52
  env: runtime.env,
38
- signal: new AbortController().signal,
53
+ signal: runtime.signal ?? new AbortController().signal,
39
54
  now: () => new Date(),
40
55
  };
41
56
  }
57
+ /**
58
+ * E2E-E3: allow a local candidate `X.Y.Z-test` to be installed by overriding
59
+ * the bundled catalog source version. Only honored when the process is pointed
60
+ * at a local release fixture (`MOEICONS_FREE_RELEASE_DIR`), never for the
61
+ * production GitHub/npm path.
62
+ */
63
+ function localFixtureSourceVersion(env) {
64
+ const fixture = env.MOEICONS_FREE_RELEASE_DIR;
65
+ const override = env.MOEICONS_SOURCE_VERSION;
66
+ if (!fixture || !override)
67
+ return undefined;
68
+ if (!isLocalTestVersion(override)) {
69
+ throw new CliError("VALIDATION_ERROR", "MOEICONS_SOURCE_VERSION override is only allowed for local X.Y.Z-test candidates");
70
+ }
71
+ return override;
72
+ }
73
+ function freeDownloadDeps(runtime) {
74
+ return {
75
+ fetchFn: globalThis.fetch.bind(globalThis),
76
+ readFileSync: (path) => new Uint8Array(readFileSync(path)),
77
+ writeFileSync: (path, data) => writeFileSync(path, data),
78
+ mkdirSync: (path) => mkdirSync(path, { recursive: true }),
79
+ existsSync,
80
+ renameSync,
81
+ rmSync,
82
+ statfs: (dir) => {
83
+ try {
84
+ const stats = statfsSync(dir);
85
+ return { availableBytes: stats.bavail * stats.bsize };
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ },
91
+ ...(runtime.env.MOEICONS_FREE_RELEASE_DIR
92
+ ? { fixtureDir: runtime.env.MOEICONS_FREE_RELEASE_DIR }
93
+ : {}),
94
+ cacheDir: runtime.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache"),
95
+ cliVersion: versionString(),
96
+ };
97
+ }
42
98
  export { MOEICONS_BANNER };
43
99
  export const BANNER = MOEICONS_BANNER;
44
- /** Banner rendered as plain ASCII (fallback for narrow terminals). */
100
+ /** Wordmark rendered for interactive wizard startup (notices come later). */
45
101
  export function renderBanner(runtime) {
46
- runtime.stdout(renderBannerText());
102
+ runtime.stdout(`${renderWordmarkText({
103
+ columns: runtime.columns?.() ?? 80,
104
+ color: createTheme(isThemeEnabled(runtime.env, runtime.isTTY())).enabled,
105
+ })}\n`);
47
106
  }
48
107
  function argvRequestsJson(argv) {
49
108
  return argv.includes("--json");
@@ -53,6 +112,13 @@ function writeJson(runtime, value) {
53
112
  }
54
113
  function reportFailure(runtime, json, error) {
55
114
  if (isCliError(error)) {
115
+ if (error.code === "CANCELLED") {
116
+ if (json)
117
+ writeJson(runtime, jsonErrorBody(error.code, error.message));
118
+ else
119
+ runtime.stderr("Cancelled\n");
120
+ return 0;
121
+ }
56
122
  if (json)
57
123
  writeJson(runtime, jsonErrorBody(error.code, error.message));
58
124
  else
@@ -83,6 +149,31 @@ export async function main(argv, runtime) {
83
149
  return reportFailure(runtime, parsed.json, error);
84
150
  }
85
151
  }
152
+ async function runBootstrap(runtime) {
153
+ const context = commandContext(runtime, { json: false, yes: false });
154
+ try {
155
+ const result = await runBootstrapUseCase(context, {
156
+ fs: {
157
+ existsSync,
158
+ readFileSync: (path) => readFileSync(path, "utf8"),
159
+ writeFileSync: (path, content) => writeFileSync(path, content),
160
+ mkdirSync: (path) => mkdirSync(path, { recursive: true }),
161
+ },
162
+ installFs: { mkdirSync, writeFileSync, existsSync, renameSync, rmSync },
163
+ free: freeDownloadDeps(runtime),
164
+ cliVersion: versionString(),
165
+ });
166
+ if (result.kind === "installed") {
167
+ runtime.stdout(`Installed moeicons Free v${result.version} with metadata into the current project.\n`);
168
+ }
169
+ else if (result.kind === "failed") {
170
+ runtime.stderr(`warning: automatic Free setup failed: ${result.message}\nRun: ${result.retry}\n`);
171
+ }
172
+ }
173
+ catch {
174
+ // Bootstrap must never block the wizard.
175
+ }
176
+ }
86
177
  async function dispatchSync(command, runtime, json, yes, noTailwind, target) {
87
178
  switch (command.name) {
88
179
  case "version":
@@ -97,45 +188,254 @@ async function dispatchSync(command, runtime, json, yes, noTailwind, target) {
97
188
  case "wizard":
98
189
  return await runWizard(runtime, json, yes);
99
190
  case "install":
100
- return await runInstall(command.group, runtime, json, noTailwind, undefined, undefined, command.target);
191
+ return await runInstall(command.group, runtime, json, noTailwind, localFixtureSourceVersion(runtime.env), undefined, command.target);
101
192
  case "login":
102
193
  return await runLogin(runtime, json, yes);
103
194
  case "logout":
104
- return await runLogout(runtime, json);
195
+ return await runLogout(runtime, json, yes);
105
196
  case "account":
106
197
  return await runAccount(runtime, json);
107
198
  case "groups":
108
199
  throw new CliError("NOT_IMPLEMENTED", "groups is not implemented yet");
109
200
  case "generate":
110
201
  return await runGenerate(runtime, json, noTailwind, false, target, yes);
202
+ case "recover": {
203
+ const root = runtime.cwd();
204
+ safeManagedPath(root, ".moeicons");
205
+ const recovered = withProjectLockSync(root, "recover", () => recoverManagedReconcile(root, {
206
+ mkdirSync,
207
+ writeFileSync,
208
+ readFileSync,
209
+ existsSync,
210
+ renameSync,
211
+ rmSync,
212
+ readdirSync,
213
+ copyFileSync,
214
+ }));
215
+ if (json)
216
+ writeJson(runtime, { ok: true, recovered });
217
+ else
218
+ runtime.stdout(`Recovered ${recovered} interrupted transaction(s). Rerun the original command.\n`);
219
+ return 0;
220
+ }
111
221
  case "init":
112
- return runInit(runtime, json, target);
222
+ return await runInit(runtime, json, yes, command.dryRun === true, target);
223
+ case "doctor":
224
+ return runDoctor(runtime, json, command.check === true, command.dryRun === true);
113
225
  case "mcp":
114
- void runMcp(runtime);
226
+ await runMcp(runtime);
115
227
  return 0;
228
+ case "update":
229
+ return await runUpdate(runtime, json, yes, command.metadata === true);
116
230
  }
117
231
  }
118
232
  async function runLogin(runtime, json, yes) {
119
- const session = await runLoginUseCase(commandContext(runtime, { json, yes }), {
233
+ const context = commandContext(runtime, { json, yes });
234
+ const environment = describeAuthEnvironment(context);
235
+ const session = await runLoginUseCase(context, {
120
236
  ...runtime.auth,
121
237
  fileFallbackAllowed: runtime.isTTY(),
122
238
  });
123
239
  if (json)
124
- writeJson(runtime, { ok: true, account: session });
240
+ writeJson(runtime, { ok: true, environment, account: session });
125
241
  else
126
- runtime.stdout(`Logged in as ${session.accountId}\n`);
242
+ runtime.stdout(`Environment: ${environment}\nLogged in as ${session.accountId}\n`);
243
+ if (!json && runtime.isTTY())
244
+ await offerProPredownload(runtime);
127
245
  return 0;
128
246
  }
247
+ /**
248
+ * After a successful interactive login, offer to pre-download the full Pro
249
+ * code + metadata. Cancelling or a failed probe never logs the user out.
250
+ */
251
+ async function offerProPredownload(runtime) {
252
+ const context = commandContext(runtime, { json: false, yes: false });
253
+ try {
254
+ const versions = await fetchLibraryVersions({
255
+ ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
256
+ signal: context.signal,
257
+ env: runtime.env,
258
+ });
259
+ if (!versions.pro) {
260
+ runtime.stdout("No Pro release is published yet.\n");
261
+ return;
262
+ }
263
+ const descriptor = await fetchProDescriptor(context, runtime.auth ?? {}, { version: versions.pro.version, descriptorSha256: versions.pro.descriptorSha256 }, runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {});
264
+ const sizes = `${formatBytes(descriptor.size)} code + ${descriptor.metadata ? formatBytes(descriptor.metadata.size) : "0 B"} metadata`;
265
+ runtime.stdout(`Pro ${versions.pro.version} available (${sizes}).\n`);
266
+ const accepted = await context.ui.confirm("Download Pro resources now? (code + metadata)", context.signal);
267
+ if (accepted !== true) {
268
+ runtime.stdout("Skipped Pro download; you can download it later from the home screen.\n");
269
+ return;
270
+ }
271
+ const progress = startResourceProgress(context, "Downloading Pro resources");
272
+ try {
273
+ const result = await runProPredownloadUseCase(context, runtime.auth ?? {}, {
274
+ ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
275
+ onProgress: ({ downloadedBytes, totalBytes }) => progress.bytes({ downloadedBytes, ...(totalBytes !== undefined ? { totalBytes } : {}) }),
276
+ });
277
+ const warmed = await warmProBitmapShards(runtime, result, runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}, progress);
278
+ progress.stop("Pro resources downloaded");
279
+ runtime.stdout(`Cached Pro ${result.version} code + metadata.\n`);
280
+ if (warmed)
281
+ runtime.stdout(`${warmed}\n`);
282
+ }
283
+ catch (error) {
284
+ progress.stop("Pro download stopped");
285
+ runtime.stderr(`warning: Pro download failed (${error instanceof Error ? error.message : String(error)}). You can retry from the home screen.\n`);
286
+ }
287
+ }
288
+ catch {
289
+ // A failed probe must not make login fail.
290
+ }
291
+ }
292
+ /**
293
+ * W3-A: normalize the optional progress-bar surface. Falls back to the legacy
294
+ * line-oriented `progress` port for adapters that do not render a bar.
295
+ */
296
+ function startResourceProgress(context, label) {
297
+ const bar = context.ui.progressBar?.(label, context.signal);
298
+ if (bar) {
299
+ return {
300
+ bytes: (event) => bar.update({
301
+ done: event.downloadedBytes,
302
+ ...(event.totalBytes !== undefined ? { total: event.totalBytes } : {}),
303
+ ...(event.detail !== undefined ? { detail: event.detail } : {}),
304
+ }),
305
+ phase: (detail) => bar.update({ detail }),
306
+ stop: (message) => bar.stop(message),
307
+ };
308
+ }
309
+ const legacy = context.ui.progress(label, context.signal);
310
+ return {
311
+ bytes: (event) => legacy.update?.(`Downloaded ${event.downloadedBytes} bytes${event.totalBytes ? ` of ${event.totalBytes}` : ""}${event.detail ? ` (${event.detail})` : ""}`),
312
+ phase: (detail) => legacy.update?.(detail),
313
+ stop: (message) => legacy.stop(message),
314
+ };
315
+ }
316
+ /**
317
+ * W2-A: best-effort warm-up of the default bitmap shard set after the Pro code
318
+ * and metadata are cached. Never fails the surrounding download.
319
+ */
320
+ async function warmProBitmapShards(runtime, result, fetchDeps, progress) {
321
+ if (!result.metadataSha256)
322
+ return undefined;
323
+ try {
324
+ const warm = await runProResourceWarmupUseCase(commandContext(runtime, { json: false, yes: false }), runtime.auth ?? {}, {
325
+ version: result.version,
326
+ descriptorSha256: result.descriptorSha256,
327
+ metadataSha256: result.metadataSha256,
328
+ catalogSha256: result.catalogSha256,
329
+ ...fetchDeps,
330
+ onPlan: (plan) => progress.phase(`Default bitmap shards: ${plan.missing.length} to download, ${plan.reused} cached`),
331
+ onProgress: (event) => progress.phase(`Shard ${event.index}/${event.total} ${event.phase} — ${event.tuple.styleGroupId} ${event.tuple.imageSize.width}x${event.tuple.imageSize.height} ${event.tuple.format}`),
332
+ });
333
+ return `Warmed ${warm.shards} default bitmap shards (${warm.downloaded} downloaded, ${warm.reused} cached).`;
334
+ }
335
+ catch (error) {
336
+ runtime.stderr(`warning: default bitmap shard warm-up skipped (${error instanceof Error ? error.message : String(error)}).\n`);
337
+ return undefined;
338
+ }
339
+ }
340
+ /**
341
+ * Home-screen Pro resources flow: show the current cache state and drive the
342
+ * matching action (download / update / repair / verify). Requires auth.
343
+ */
344
+ async function runProResources(runtime, yes) {
345
+ const context = commandContext(runtime, { json: false, yes });
346
+ const fetchDeps = runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {};
347
+ const state = await proResourceState(context, runtime.auth ?? {}, fetchDeps);
348
+ if (state.kind === "unavailable") {
349
+ runtime.stdout(`Pro resources unavailable: ${state.reason}\n`);
350
+ return 0;
351
+ }
352
+ let action = "";
353
+ if (state.kind === "current") {
354
+ runtime.stdout(`Pro resources are up to date (v${state.version}).\n`);
355
+ return 0;
356
+ }
357
+ if (state.kind === "not-cached") {
358
+ runtime.stdout(`Pro ${state.version}: ${formatBytes(state.codeBytes)} code + ${formatBytes(state.metadataBytes)} metadata.\n`);
359
+ action = "Download";
360
+ }
361
+ else if (state.kind === "outdated") {
362
+ runtime.stdout(`Pro ${state.version} → ${state.latestVersion}: ${formatBytes(state.codeBytes)} code + ${formatBytes(state.metadataBytes)} metadata.\n`);
363
+ action = "Update";
364
+ }
365
+ else {
366
+ runtime.stdout(`Pro ${state.version} cache is corrupt; re-downloading.\n`);
367
+ action = "Repair";
368
+ }
369
+ const confirmed = await context.ui.confirm(`${action} Pro resources now? (code + metadata)`, context.signal);
370
+ if (confirmed !== true) {
371
+ runtime.stdout("Cancelled; Pro resources were not downloaded.\n");
372
+ return 0;
373
+ }
374
+ const progress = startResourceProgress(context, "Downloading Pro resources");
375
+ try {
376
+ const result = await runProPredownloadUseCase(context, runtime.auth ?? {}, {
377
+ ...fetchDeps,
378
+ force: state.kind === "corrupt",
379
+ onProgress: ({ downloadedBytes, totalBytes }) => progress.bytes({ downloadedBytes, ...(totalBytes !== undefined ? { totalBytes } : {}) }),
380
+ });
381
+ const warmed = await warmProBitmapShards(runtime, result, fetchDeps, progress);
382
+ progress.stop("Pro resources downloaded");
383
+ runtime.stdout(`Cached Pro ${result.version} code + metadata.\n`);
384
+ if (warmed)
385
+ runtime.stdout(`${warmed}\n`);
386
+ return 0;
387
+ }
388
+ catch (error) {
389
+ progress.stop("Pro download stopped");
390
+ if (error instanceof CliError && (error.code === "FORBIDDEN" || error.code === "AUTH_ERROR")) {
391
+ throw error;
392
+ }
393
+ runtime.stderr(`warning: Pro download failed (${error instanceof Error ? error.message : String(error)}). Retry from this menu.\n`);
394
+ return 0;
395
+ }
396
+ }
129
397
  async function runAccount(runtime, json) {
130
- const session = await runAccountUseCase(commandContext(runtime, { json, yes: false }), runtime.auth);
398
+ const context = commandContext(runtime, { json, yes: false });
399
+ const environment = describeAuthEnvironment(context);
400
+ const session = await runAccountUseCase(context, runtime.auth);
401
+ // H6/account: never silently report success when the Worker entitlement
402
+ // lookup failed. JSON/automation fails non-zero; interactive mode says so.
403
+ let remote;
404
+ let remoteError;
405
+ try {
406
+ remote = await runRemoteAccountUseCase(commandContext(runtime, { json, yes: false }), {
407
+ ...runtime.auth,
408
+ fetch: runtime.auth?.fetch ?? globalThis.fetch.bind(globalThis),
409
+ });
410
+ }
411
+ catch (error) {
412
+ remoteError = error;
413
+ }
414
+ if (json && remoteError)
415
+ throw remoteError;
416
+ const account = { ...session, ...(remote ?? {}) };
131
417
  if (json)
132
- writeJson(runtime, { ok: true, account: session });
133
- else
134
- runtime.stdout(`Account: ${session.accountId}\nSession expires: ${new Date(session.expiresAt).toISOString()}\n`);
418
+ writeJson(runtime, { ok: true, environment, account });
419
+ else {
420
+ runtime.stdout(`Environment: ${environment}\nAccount: ${account.accountId}\nSession expires: ${new Date(account.expiresAt).toISOString()}\n`);
421
+ if (remote)
422
+ runtime.stdout(`Tier: ${account.tier}\nEntitlement: ${account.entitlementStatus}\n`);
423
+ else
424
+ runtime.stdout("Remote account: unavailable\n");
425
+ }
135
426
  return 0;
136
427
  }
137
- async function runLogout(runtime, json) {
138
- const result = await runLogoutUseCase(commandContext(runtime, { json, yes: false }), runtime.auth);
428
+ async function runLogout(runtime, json, yes) {
429
+ const context = commandContext(runtime, { json, yes });
430
+ // H3: interactive logout is explicit; declining leaves credentials intact.
431
+ if (!json && !yes && runtime.isTTY()) {
432
+ const confirmed = await context.ui.confirm("Log out and revoke the current session?", context.signal);
433
+ if (confirmed !== true) {
434
+ runtime.stdout("Cancelled; you are still logged in.\n");
435
+ return 0;
436
+ }
437
+ }
438
+ const result = await runLogoutUseCase(context, runtime.auth);
139
439
  if (json)
140
440
  writeJson(runtime, { ok: true, ...result });
141
441
  else
@@ -144,42 +444,126 @@ async function runLogout(runtime, json) {
144
444
  : "Logged out locally; remote revocation was not confirmed.\n");
145
445
  return 0;
146
446
  }
147
- /** Create moeicons.config.jsonc if absent (never overwrites an existing config). */
148
- function runInit(runtime, json, target) {
149
- const result = runInitUseCase(commandContext(runtime, { json, yes: false }), { mkdirSync, writeFileSync, existsSync, renameSync, rmSync }, target);
150
- if (!result.ok && result.reason === "exists") {
447
+ /**
448
+ * Four-anchor init (E2E-B5): diagnose → plan → confirm/--yes → transactional apply.
449
+ * `--dry-run` never writes. Non-TTY / `--json` requires `--yes` to write.
450
+ */
451
+ async function runInit(runtime, json, yes, dryRun = false, target) {
452
+ const { outcome } = buildInitPlan(runtime.cwd(), target);
453
+ const { report } = outcome;
454
+ const fixes = collectSafeFixes(outcome);
455
+ const manifest = report.anchors.find((a) => a.kind === "manifest");
456
+ if (!manifest || manifest.status === "missing" || manifest.status === "invalid") {
457
+ throw new CliError("VALIDATION_ERROR", "no project found");
458
+ }
459
+ if (dryRun) {
460
+ if (json) {
461
+ writeJson(runtime, {
462
+ ...doctorJson(report),
463
+ mode: "dry-run",
464
+ diffs: plannedDiffsJson(fixes),
465
+ });
466
+ }
467
+ else {
468
+ runtime.stdout(`${formatDoctorReport(report)}\n`);
469
+ const diffs = formatPlannedDiffs(fixes);
470
+ if (diffs)
471
+ runtime.stdout(`${diffs}\n`);
472
+ }
473
+ return 0;
474
+ }
475
+ if (fixes.length === 0) {
476
+ if (report.anchors.some((anchor) => anchor.status === "ambiguous"))
477
+ throw new CliError("VALIDATION_ERROR", "select --target before initializing this project");
151
478
  if (json)
152
- writeJson(runtime, { ok: true, alreadyExisted: true });
479
+ writeJson(runtime, { ok: true, alreadyConfigured: true, ...doctorJson(report) });
153
480
  else
154
- runtime.stdout("moeicons.config.jsonc already exists; not overwritten.\n");
481
+ runtime.stdout("already configured\n");
155
482
  return 0;
156
483
  }
157
- if (!result.ok) {
158
- throw new CliError("VALIDATION_ERROR", result.reason === "no-project" ? "no project found" : "init failed");
484
+ if (!json) {
485
+ runtime.stdout(`${formatDoctorReport(report)}\n`);
486
+ const diffs = formatPlannedDiffs(fixes);
487
+ if (diffs)
488
+ runtime.stdout(`${diffs}\n`);
489
+ }
490
+ const context = commandContext(runtime, { json, yes });
491
+ const confirmed = await context.ui.confirm(`Apply ${fixes.length} planned change(s)?`, context.signal);
492
+ if (confirmed !== true) {
493
+ if (json)
494
+ writeJson(runtime, { ok: true, cancelled: true });
495
+ else
496
+ runtime.stdout("Cancelled; no files written.\n");
497
+ return 0;
498
+ }
499
+ const result = runDoctorApply(runtime.cwd(), fixes);
500
+ if (result.ok === false) {
501
+ throw new CliError("UNEXPECTED", result.message);
502
+ }
503
+ if (result.alreadyConfigured) {
504
+ if (json)
505
+ writeJson(runtime, { ok: true, alreadyConfigured: true, written: [] });
506
+ else
507
+ runtime.stdout("already configured\n");
508
+ return 0;
159
509
  }
160
510
  if (json)
161
- writeJson(runtime, { ok: true, created: result.created });
511
+ writeJson(runtime, { ok: true, written: result.written ?? [] });
162
512
  else
163
- runtime.stdout(`Created ${result.created}\n`);
513
+ runtime.stdout(`Applied ${(result.written ?? []).join(", ")}\n`);
164
514
  return 0;
165
515
  }
516
+ /** Read-only four-anchor diagnosis command (`doctor`). */
517
+ function runDoctor(runtime, json, check, dryRun) {
518
+ const { report } = runDoctorDiagnose(runtime.cwd());
519
+ void dryRun; // doctor is always read-only; --dry-run is accepted for parity
520
+ if (json)
521
+ writeJson(runtime, doctorJson(report));
522
+ else
523
+ runtime.stdout(formatDoctorReport(report));
524
+ return check ? (checkRequiresFix(report) ? 1 : 0) : 0;
525
+ }
166
526
  /** Start the MCP stdio server; protocol data to stdout, logs to stderr. */
527
+ /**
528
+ * AUD-CL-01: the MCP surface exposes no single style-group install. It fails
529
+ * closed with guidance rather than reporting a fake success.
530
+ */
531
+ export function createMcpServices(runtime) {
532
+ return createProjectMcpServices(runtime, async (argv) => {
533
+ let output = "";
534
+ const status = await main(argv, { ...runtime, isTTY: () => false, stdout: (text) => { output += text; }, stderr: (text) => runtime.stderr(String(safeMcpValue(text)).split(runtime.cwd()).join(".")) });
535
+ try {
536
+ return JSON.parse(output);
537
+ }
538
+ catch {
539
+ return { ok: false, code: "COMMAND_FAILED", message: `command returned ${status} without a JSON result` };
540
+ }
541
+ });
542
+ }
167
543
  async function runMcp(runtime) {
168
544
  const { runMcpStdio } = await import("./mcp/server.js");
545
+ if (runtime.mcpLines) {
546
+ await runMcpStdio({ services: createMcpServices(runtime), projectRoot: runtime.cwd(), stdout: runtime.stdout, stderr: runtime.stderr, lines: runtime.mcpLines, ...(runtime.signal ? { signal: runtime.signal } : {}) });
547
+ return;
548
+ }
169
549
  const readline = await import("node:readline");
170
- const services = {
171
- listIconGroups: () => Promise.resolve([
172
- { id: "free", displayName: "Free icons" },
173
- { id: "pro", displayName: "Pro icons" },
174
- ]),
175
- getAccount: () => Promise.resolve(undefined),
176
- installIconGroup: (args) => {
177
- runtime.stderr(`installing ${args.groupId} into ${args.projectPath} (stub)\n`);
178
- return Promise.resolve({ ok: true, message: `installed ${args.groupId}` });
179
- },
180
- };
550
+ const controller = new AbortController();
551
+ const services = createMcpServices({ ...runtime, signal: controller.signal });
181
552
  const rl = readline.createInterface({ input: process.stdin });
182
- await runMcpStdio({ services, stdout: runtime.stdout, stderr: runtime.stderr, lines: rl });
553
+ const stop = () => { controller.abort(); rl.close(); };
554
+ const eof = () => controller.abort();
555
+ process.once("SIGINT", stop);
556
+ process.once("SIGTERM", stop);
557
+ rl.once("close", eof);
558
+ try {
559
+ await runMcpStdio({ services, projectRoot: runtime.cwd(), stdout: runtime.stdout, stderr: runtime.stderr, lines: rl, signal: controller.signal });
560
+ }
561
+ finally {
562
+ process.removeListener("SIGINT", stop);
563
+ process.removeListener("SIGTERM", stop);
564
+ rl.removeListener("close", eof);
565
+ rl.close();
566
+ }
183
567
  }
184
568
  function versionString() {
185
569
  return CLI_VERSION;
@@ -197,7 +581,15 @@ function renderCliUpdateNotice(update) {
197
581
  }
198
582
  /** Guided flow: Free / Pro / Login, with project-root confirmation before write. */
199
583
  async function runWizard(runtime, json, yes) {
200
- if (!json && runtime.isTTY()) {
584
+ if (json) {
585
+ const context = commandContext(runtime, { json, yes });
586
+ const result = await runWizardUseCase(context, { json: true });
587
+ if (result.ok && result.action === "json-hint") {
588
+ writeJson(runtime, { ok: true, message: result.message });
589
+ }
590
+ return 0;
591
+ }
592
+ if (runtime.isTTY()) {
201
593
  renderBanner(runtime);
202
594
  runtime.stdout(`CLI ${versionString()}\n`);
203
595
  try {
@@ -212,14 +604,15 @@ async function runWizard(runtime, json, yes) {
212
604
  runtime.stdout(`${renderCliUpdateNotice(update)}\n`);
213
605
  }
214
606
  catch {
215
- runtime.stdout(`${renderNoticeBox(["Moeicons CLI version status", `Current ${versionString()} / Latest unavailable / Update: unavailable`])}\n`);
607
+ runtime.stdout(`${renderNoticeBox([
608
+ "Moeicons CLI version status",
609
+ `Current ${versionString()} / Latest unavailable / Update: unavailable`,
610
+ ])}\n`);
216
611
  }
612
+ runtime.stdout(`${renderProjectNotice(runtime.columns?.() ?? 80)}\n`);
613
+ if (runtime.readLine === undefined)
614
+ await runBootstrap(runtime);
217
615
  }
218
- const context = commandContext(runtime, { json, yes });
219
- const session = await runSessionStatusUseCase(context, runtime.auth).catch((error) => ({
220
- kind: "unknown",
221
- reason: error instanceof Error ? error.message : "session status unavailable",
222
- }));
223
616
  const getStatus = async () => {
224
617
  const project = detectProject(runtime.cwd());
225
618
  if (!project)
@@ -229,70 +622,180 @@ async function runWizard(runtime, json, yes) {
229
622
  return `Current: invalid / Latest: unavailable / Status: config ${config.kind}`;
230
623
  return formatLibraryVersionStatus(await getLibraryVersionStatus(project.root, config.config.tier));
231
624
  };
232
- const result = await runWizardUseCase(context, {
233
- json,
234
- session: session.kind,
235
- getLibraryStatus: getStatus,
236
- });
237
- if (result.ok && result.action === "json-hint") {
238
- writeJson(runtime, { ok: true, message: result.message });
239
- return 0;
240
- }
241
- if (!result.ok) {
242
- throw new CliError("CANCELLED", "cancelled");
243
- }
244
- if (result.action === "install") {
245
- const project = detectProject(runtime.cwd());
246
- if (project)
247
- runtime.stdout(`Project root: ${project.root}\n`);
248
- return await runInstall(result.group, runtime, false, false, undefined, undefined, result.target);
249
- }
250
- if (result.action === "settings") {
251
- if (result.flow === "logout")
252
- return runLogout(runtime, false);
253
- const update = await runCliUpdateCheck({
254
- currentVersion: versionString(),
255
- cwd: runtime.cwd(),
256
- env: runtime.env,
257
- fs: { existsSync, readFileSync: (path) => readFileSync(path, "utf8") },
258
- signal: context.signal,
259
- timeoutMs: 1_000,
260
- ...(runtime.fetchVersions ? { fetchVersions: runtime.fetchVersions } : {}),
261
- }).catch(() => undefined);
262
- if (!update) {
263
- runtime.stdout(`${renderNoticeBox(["Moeicons CLI version status", `Current ${versionString()} / Latest unavailable / Update: unavailable`])}\n`);
625
+ // W3-E2: single-line preview of the most recent task, colour-coded by state.
626
+ const theme = createTheme(isThemeEnabled(runtime.env, runtime.isTTY()));
627
+ let lastTask;
628
+ // Single-level loop: never recurse runWizard; banner/bootstrap once per process.
629
+ for (;;) {
630
+ const context = commandContext(runtime, { json: false, yes });
631
+ const session = await runSessionStatusUseCase(context, runtime.auth).catch((error) => ({
632
+ kind: "unknown",
633
+ reason: error instanceof Error ? error.message : "session status unavailable",
634
+ }));
635
+ const historyLine = renderHistoryBar(lastTask, theme);
636
+ const result = await runWizardUseCase(context, {
637
+ json: false,
638
+ session: session.kind,
639
+ ...(historyLine ? { historyLine } : {}),
640
+ getLibraryStatus: getStatus,
641
+ getProResourceLabel: async () => {
642
+ if (session.kind !== "authenticated")
643
+ return undefined;
644
+ const state = await proResourceState(context, runtime.auth ?? {}, {
645
+ ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
646
+ }).catch(() => undefined);
647
+ if (!state || state.kind === "unavailable")
648
+ return undefined;
649
+ if (state.kind === "not-cached")
650
+ return "Download Pro resources";
651
+ if (state.kind === "outdated")
652
+ return `Update Pro resources (${state.version} → ${state.latestVersion})`;
653
+ if (state.kind === "corrupt")
654
+ return "Repair Pro resources";
655
+ return "Pro resources are up to date";
656
+ },
657
+ });
658
+ if (!result.ok) {
659
+ // Select/confirm cancel frames already render "■ Cancelled"; do not duplicate.
264
660
  return 0;
265
661
  }
266
- if (update.status === "update")
267
- runtime.stdout(`CLI ${update.currentVersion} → ${update.latestVersion}\nRun: ${update.instruction}\n`);
268
- else if (update.status === "current")
269
- runtime.stdout(`CLI ${update.currentVersion} is up to date.\n`);
270
- else
271
- runtime.stdout(`Unable to find a CLI release in the current channel.\n`);
662
+ if (result.action === "exit") {
663
+ if (result.via === "menu")
664
+ runtime.stdout("Exited\n");
665
+ return 0;
666
+ }
667
+ if (result.action === "back")
668
+ continue;
669
+ if (result.action === "pro-resources") {
670
+ const code = await runProResources(runtime, yes);
671
+ lastTask = {
672
+ label: "Pro resources",
673
+ state: code === 0 ? "success" : "failure",
674
+ };
675
+ continue;
676
+ }
677
+ if (result.action === "install") {
678
+ const project = detectProject(runtime.cwd());
679
+ if (project)
680
+ runtime.stdout(`Project root: ${project.root}\n`);
681
+ // W3-E1: never leave the user stuck at the end of a task; return to the
682
+ // home menu (with the history bar) instead of exiting.
683
+ const code = await runInstall(result.group, runtime, false, false, undefined, undefined, result.target);
684
+ lastTask = {
685
+ label: `Install ${result.group} (${result.target})`,
686
+ state: code === 0 ? "success" : "failure",
687
+ };
688
+ continue;
689
+ }
690
+ if (result.action === "settings") {
691
+ if (result.flow === "logout") {
692
+ await runLogout(runtime, false, true);
693
+ continue;
694
+ }
695
+ const update = await runCliUpdateCheck({
696
+ currentVersion: versionString(),
697
+ cwd: runtime.cwd(),
698
+ env: runtime.env,
699
+ fs: { existsSync, readFileSync: (path) => readFileSync(path, "utf8") },
700
+ signal: context.signal,
701
+ timeoutMs: 1_000,
702
+ ...(runtime.fetchVersions ? { fetchVersions: runtime.fetchVersions } : {}),
703
+ }).catch(() => undefined);
704
+ if (!update) {
705
+ runtime.stdout(`${renderNoticeBox([
706
+ "Moeicons CLI version status",
707
+ `Current ${versionString()} / Latest unavailable / Update: unavailable`,
708
+ ])}\n`);
709
+ }
710
+ else if (update.status === "update") {
711
+ runtime.stdout(`CLI ${update.currentVersion} → ${update.latestVersion}\nRun: ${update.instruction}\n`);
712
+ }
713
+ else if (update.status === "current") {
714
+ runtime.stdout(`CLI ${update.currentVersion} is up to date.\n`);
715
+ }
716
+ else {
717
+ runtime.stdout(`Unable to find a CLI release in the current channel.\n`);
718
+ }
719
+ continue;
720
+ }
721
+ if (result.action === "manage") {
722
+ if (result.flow === "reload") {
723
+ const code = await runGenerate(runtime, false, false, true, undefined, yes);
724
+ lastTask = { label: "Update project resources", state: code === 0 ? "success" : "failure" };
725
+ continue;
726
+ }
727
+ const project = detectProject(runtime.cwd());
728
+ if (!project)
729
+ throw new CliError("VALIDATION_ERROR", "no project found");
730
+ const config = readMoeiconsConfig(project.root);
731
+ if (config.kind !== "ok")
732
+ throw new CliError("VALIDATION_ERROR", `config ${config.kind}`);
733
+ assertDownloadSelection(config.config);
734
+ const status = await getLibraryVersionStatus(project.root, config.config.tier);
735
+ runtime.stdout(`${formatLibraryVersionStatus(status)}\n`);
736
+ if (status.kind === "update") {
737
+ const code = await runLibraryUpdate(runtime, status.metadata.tier, status.latestVersion, status.latestDescriptorSha256);
738
+ lastTask = { label: "Update icon library", state: code === 0 ? "success" : "failure" };
739
+ }
740
+ else {
741
+ lastTask = { label: "Update icon library", state: "success", detail: "already current" };
742
+ }
743
+ continue;
744
+ }
745
+ if (result.action === "pending" && result.flow === "login") {
746
+ const loginOutcome = await runWizardLogin(runtime, yes);
747
+ if (loginOutcome === "exit")
748
+ return 0;
749
+ lastTask = { label: "Login", state: loginOutcome === "home" ? "success" : "failure" };
750
+ continue;
751
+ }
272
752
  return 0;
273
753
  }
274
- if (result.action === "manage") {
275
- if (result.flow === "reload")
276
- return await runGenerate(runtime, false, false, true, undefined, yes);
277
- const project = detectProject(runtime.cwd());
278
- if (!project)
279
- throw new CliError("VALIDATION_ERROR", "no project found");
280
- const config = readMoeiconsConfig(project.root);
281
- if (config.kind !== "ok")
282
- throw new CliError("VALIDATION_ERROR", `config ${config.kind}`);
283
- const status = await getLibraryVersionStatus(project.root, config.config.tier);
284
- runtime.stdout(`${formatLibraryVersionStatus(status)}\n`);
285
- if (status.kind !== "update")
286
- return 0;
287
- return runLibraryUpdate(runtime, status.metadata.tier, status.latestVersion, status.latestDescriptorSha256);
754
+ }
755
+ /**
756
+ * Wizard-local login with Retry/Back. Explicit `moeicons login` still uses runLogin.
757
+ * Returns "home" to resume the wizard loop, or "exit" on cancel/Ctrl+C.
758
+ */
759
+ async function runWizardLogin(runtime, yes) {
760
+ for (;;) {
761
+ try {
762
+ const code = await runLogin(runtime, false, yes);
763
+ if (code !== 0) {
764
+ // runLogin only returns non-zero via thrown errors normally; keep home on soft failure.
765
+ return "home";
766
+ }
767
+ return "home";
768
+ }
769
+ catch (error) {
770
+ if (isCliError(error) && error.code === "CANCELLED") {
771
+ runtime.stderr("Cancelled\n");
772
+ return "exit";
773
+ }
774
+ const message = isCliError(error)
775
+ ? error.message
776
+ : error instanceof Error
777
+ ? error.message
778
+ : String(error);
779
+ runtime.stderr(`Login failed: ${message}\n`);
780
+ const context = commandContext(runtime, { json: false, yes });
781
+ const choice = await context.ui.select("Login", loginRecoveryChoices(), context.signal);
782
+ if (choice === undefined) {
783
+ // Select cancel frame already rendered "■ Cancelled"; do not duplicate.
784
+ return "exit";
785
+ }
786
+ if (choice === "back")
787
+ return "home";
788
+ // retry → loop with a fresh session
789
+ }
288
790
  }
289
- if (result.flow === "login")
290
- return runLogin(runtime, false, yes);
291
- return 0;
292
791
  }
293
- async function runLibraryUpdate(runtime, tier, version, descriptorSha256) {
294
- const context = commandContext(runtime, { json: false, yes: false });
295
- const progress = context.ui.progress("Downloading icon library update", context.signal);
792
+ async function runLibraryUpdate(runtime, tier, version, descriptorSha256, json = false) {
793
+ const context = commandContext(runtime, { json, yes: false });
794
+ // W3-A: a single progress surface. `task` is deliberately NOT used here: the
795
+ // clack adapter implements both with `p.spinner()`, so stacking them would
796
+ // drive two concurrent spinners over stdout. The bar's stop message already
797
+ // carries the success/failure status.
798
+ const progress = startResourceProgress(context, "Downloading icon library update");
296
799
  try {
297
800
  const result = await runLibraryUpdateUseCase(context, {
298
801
  fs: {
@@ -305,24 +808,20 @@ async function runLibraryUpdate(runtime, tier, version, descriptorSha256) {
305
808
  readdirSync,
306
809
  copyFileSync,
307
810
  },
308
- free: {
309
- fetchFn: globalThis.fetch.bind(globalThis),
310
- readFileSync: (path) => new Uint8Array(readFileSync(path)),
311
- writeFileSync: (path, data) => writeFileSync(path, data),
312
- mkdirSync: (path) => mkdirSync(path, { recursive: true }),
313
- existsSync,
314
- ...(runtime.env.MOEICONS_FREE_RELEASE_DIR
315
- ? { fixtureDir: runtime.env.MOEICONS_FREE_RELEASE_DIR }
316
- : {}),
317
- cacheDir: runtime.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache"),
318
- cliVersion: versionString(),
319
- },
811
+ free: freeDownloadDeps(runtime),
320
812
  auth: runtime.auth ?? {},
321
813
  fetch: runtime.auth?.fetch ?? globalThis.fetch.bind(globalThis),
322
- onProgress: ({ downloadedBytes, totalBytes }) => progress.update?.(`Downloaded ${downloadedBytes} bytes${totalBytes ? ` of ${totalBytes}` : ""}`),
814
+ onProgress: ({ downloadedBytes, totalBytes }) => progress.bytes({ downloadedBytes, ...(totalBytes !== undefined ? { totalBytes } : {}) }),
323
815
  }, { tier, version, descriptorSha256 });
324
816
  progress.stop("Icon library update complete");
325
- runtime.stdout(`Updated ${tier} artifact to ${result.artifactVersion}; reconciled ${result.files.length} generated files.\n`);
817
+ if (json)
818
+ writeJson(runtime, { ok: true, ...result });
819
+ else {
820
+ runtime.stdout(`Updated ${tier} artifact to ${result.artifactVersion}; reconciled ${result.files.length} generated files.\n`);
821
+ runtime.stdout(`Download mode: ${result.downloadMode}${result.selectedFiles !== undefined ? `; ${result.selectedFiles} resource files; ${result.networkBytes} resource bytes received` : ""}.\n`);
822
+ for (const note of result.downloadNotes)
823
+ runtime.stdout(`${note}\n`);
824
+ }
326
825
  return 0;
327
826
  }
328
827
  catch (error) {
@@ -330,15 +829,104 @@ async function runLibraryUpdate(runtime, tier, version, descriptorSha256) {
330
829
  throw error;
331
830
  }
332
831
  }
832
+ /**
833
+ * `update` command: `update metadata` syncs only the small metadata archive for
834
+ * the installed version; `update` performs a full code + metadata update.
835
+ */
836
+ async function runUpdate(runtime, json, yes, metadataOnly) {
837
+ const context = commandContext(runtime, { json, yes });
838
+ if (metadataOnly) {
839
+ try {
840
+ const result = await runMetadataSyncUseCase(context, {
841
+ fs: {
842
+ mkdirSync,
843
+ writeFileSync,
844
+ readFileSync,
845
+ existsSync,
846
+ renameSync,
847
+ rmSync,
848
+ readdirSync,
849
+ copyFileSync,
850
+ },
851
+ free: freeDownloadDeps(runtime),
852
+ auth: runtime.auth ?? {},
853
+ ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
854
+ }, {});
855
+ if (json)
856
+ writeJson(runtime, { ok: true, ...result });
857
+ else
858
+ runtime.stdout(`Synced ${result.tier} metadata ${result.artifactVersion} into ${result.projectRoot}.\n`);
859
+ return 0;
860
+ }
861
+ catch (error) {
862
+ if (json)
863
+ return reportFailure(runtime, true, error);
864
+ throw error;
865
+ }
866
+ }
867
+ const project = detectProject(runtime.cwd());
868
+ if (!project)
869
+ throw new CliError("VALIDATION_ERROR", "no project found; run install first");
870
+ const config = validateConfigDocument(loadConfigDocument(project.root), undefined, {
871
+ lenientCatalog: true,
872
+ });
873
+ if (config.kind !== "ok")
874
+ throw new CliError("VALIDATION_ERROR", config.kind === "invalid" ? config.message : `config ${config.kind}; run moeicons init`);
875
+ assertDownloadSelection(config.config);
876
+ const { allowLocalTest } = resolveLocalTestContext(runtime.env);
877
+ const status = await getLibraryVersionStatus(project.root, config.config.tier, {
878
+ fetchVersions: () => fetchLibraryVersions({
879
+ signal: context.signal,
880
+ env: runtime.env,
881
+ allowLocalTest,
882
+ ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
883
+ }),
884
+ });
885
+ if (context.signal?.aborted)
886
+ throw new CliError("CANCELLED", "update cancelled");
887
+ if (status.kind === "check-failed")
888
+ throw new CliError("NETWORK_ERROR", status.message);
889
+ if (status.kind === "installation-invalid")
890
+ throw new CliError("VALIDATION_ERROR", status.message);
891
+ if (status.kind === "current" &&
892
+ status.latestDescriptorSha256 !== status.metadata.descriptorSha256)
893
+ throw new CliError("VALIDATION_ERROR", "published identity changed for the installed version; refusing to replace the pinned release");
894
+ if (status.kind !== "update" && status.kind !== "current") {
895
+ if (json)
896
+ writeJson(runtime, { ok: true, status: status.kind });
897
+ else
898
+ runtime.stdout(`${formatLibraryVersionStatus(status)}\n`);
899
+ return 0;
900
+ }
901
+ return runLibraryUpdate(runtime, status.metadata.tier, status.latestVersion, status.latestDescriptorSha256, json);
902
+ }
333
903
  /** Free install orchestration: download/verify then map to JSON/human output. */
334
904
  async function runInstall(group, runtime, json, noTailwind, sourceVersion, expectedDescriptorSha256, target) {
335
905
  const context = commandContext(runtime, { json, yes: false });
906
+ const project = detectProject(runtime.cwd());
907
+ if (!project)
908
+ throw new CliError("VALIDATION_ERROR", "no package.json found in the current directory or parents; run inside a project");
909
+ const bootstrap = validateConfigDocument(loadConfigDocument(project.root), undefined, {
910
+ lenientCatalog: true,
911
+ });
912
+ if (bootstrap.kind === "invalid")
913
+ throw new CliError("VALIDATION_ERROR", bootstrap.message);
914
+ if (bootstrap.kind === "ok")
915
+ assertDownloadSelection(bootstrap.config);
916
+ const requestedTier = group === "pro" || group === "ent" ? "pro" : "free";
917
+ if (bootstrap.kind === "ok" && bootstrap.config.tier !== requestedTier)
918
+ throw new CliError("VALIDATION_ERROR", `config.tier=${bootstrap.config.tier} conflicts with install ${requestedTier}; run moeicons install ${bootstrap.config.tier} or edit config.tier`);
919
+ if (bootstrap.kind === "ok" && target && bootstrap.config.target !== target)
920
+ throw new CliError("VALIDATION_ERROR", `config.target=${bootstrap.config.target} conflicts with --target=${target}; edit config.target before downloading`);
336
921
  const progress = context.ui.progress("Downloading icon library", context.signal);
337
922
  if (group === "pro" || group === "ent") {
923
+ const { allowLocalTest } = resolveLocalTestContext(runtime.env);
338
924
  const identity = sourceVersion && expectedDescriptorSha256
339
925
  ? { version: sourceVersion, descriptorSha256: expectedDescriptorSha256 }
340
926
  : await fetchLibraryVersions({
341
927
  signal: context.signal,
928
+ env: runtime.env,
929
+ allowLocalTest,
342
930
  ...(runtime.auth?.fetch ? { fetch: runtime.auth.fetch } : {}),
343
931
  }).then((versions) => {
344
932
  if (!versions.pro)
@@ -350,7 +938,16 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
350
938
  });
351
939
  try {
352
940
  const result = await runProInstallUseCase(context, {
353
- fs: { mkdirSync, writeFileSync, existsSync, renameSync, rmSync },
941
+ fs: {
942
+ mkdirSync,
943
+ writeFileSync,
944
+ existsSync,
945
+ renameSync,
946
+ rmSync,
947
+ readFileSync,
948
+ readdirSync,
949
+ copyFileSync,
950
+ },
354
951
  auth: runtime.auth ?? {},
355
952
  fetch: runtime.auth?.fetch ?? globalThis.fetch.bind(globalThis),
356
953
  onProgress: ({ downloadedBytes, totalBytes }) => {
@@ -359,12 +956,20 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
359
956
  : "";
360
957
  progress.update?.(`Downloaded ${downloadedBytes} bytes${totalBytes ? ` of ${totalBytes}` : ""}${percent}`);
361
958
  },
362
- }, { ...identity, ...(target ? { target } : {}) });
959
+ }, {
960
+ ...identity,
961
+ ...(allowLocalTest ? { allowLocalTest: true } : {}),
962
+ ...(target ? { target } : {}),
963
+ });
363
964
  progress.stop("Icon library download complete");
364
965
  if (json)
365
966
  writeJson(runtime, { ok: true, group: "pro", ...result });
366
- else
967
+ else {
367
968
  runtime.stdout(`Installed pro artifact ${result.artifactVersion} into ${result.projectRoot}.\n`);
969
+ runtime.stdout(`Download mode: ${result.downloadMode}${result.selectedFiles !== undefined ? `; ${result.selectedFiles} resource files; ${result.networkBytes} resource bytes received` : ""}.\n`);
970
+ for (const note of result.downloadNotes)
971
+ runtime.stdout(`${note}\n`);
972
+ }
368
973
  return 0;
369
974
  }
370
975
  catch (error) {
@@ -375,16 +980,7 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
375
980
  const result = await runInstallUseCase(context, {
376
981
  fs: { mkdirSync, writeFileSync, existsSync, renameSync, rmSync },
377
982
  download: {
378
- fetchFn: globalThis.fetch.bind(globalThis),
379
- readFileSync: (path) => new Uint8Array(readFileSync(path)),
380
- writeFileSync: (path, data) => writeFileSync(path, data),
381
- mkdirSync: (path) => mkdirSync(path, { recursive: true }),
382
- existsSync,
383
- ...(runtime.env.MOEICONS_FREE_RELEASE_DIR
384
- ? { fixtureDir: runtime.env.MOEICONS_FREE_RELEASE_DIR }
385
- : {}),
386
- cacheDir: runtime.env.MOEICONS_CACHE_DIR ?? join(homedir(), ".moeicons", "cache"),
387
- cliVersion: versionString(),
983
+ ...freeDownloadDeps(runtime),
388
984
  onProgress: ({ downloadedBytes, totalBytes }) => {
389
985
  const percent = totalBytes
390
986
  ? ` (${Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))}%)`
@@ -414,6 +1010,9 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
414
1010
  if (!result.ok && result.reason === "not-found") {
415
1011
  throw new CliError("NOT_FOUND", result.message);
416
1012
  }
1013
+ if (!result.ok && result.reason === "disk-full") {
1014
+ throw new CliError("DISK_FULL", result.message);
1015
+ }
417
1016
  if (!result.ok && result.reason === "validation") {
418
1017
  throw new CliError("VALIDATION_ERROR", result.message);
419
1018
  }
@@ -433,7 +1032,12 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
433
1032
  artifactVersion: result.artifactVersion,
434
1033
  descriptorSha256: result.descriptorSha256,
435
1034
  catalogSha256: result.catalogSha256,
1035
+ metadataSha256: result.metadataSha256,
436
1036
  cacheHit: result.cacheHit,
1037
+ downloadMode: result.downloadMode,
1038
+ downloadNotes: result.downloadNotes,
1039
+ networkBytes: result.networkBytes,
1040
+ selectedFiles: result.selectedFiles,
437
1041
  });
438
1042
  }
439
1043
  else {
@@ -441,6 +1045,9 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
441
1045
  runtime.stdout(`Group: ${result.group}\n`);
442
1046
  runtime.stdout(`Artifact: ${result.artifactVersion}\n`);
443
1047
  runtime.stdout(`Config state: ${result.config}\n`);
1048
+ runtime.stdout(`Download mode: ${result.downloadMode}${result.selectedFiles !== undefined ? `; ${result.selectedFiles} resource files; ${result.networkBytes} resource bytes received` : ""}.\n`);
1049
+ for (const note of result.downloadNotes ?? [])
1050
+ runtime.stdout(`${note}\n`);
444
1051
  runtime.stdout(`Installed free artifact metadata to .moeicons (cacheHit=${String(result.cacheHit)}).\n`);
445
1052
  }
446
1053
  return 0;
@@ -448,24 +1055,42 @@ async function runInstall(group, runtime, json, noTailwind, sourceVersion, expec
448
1055
  function generateFailureCode(reason) {
449
1056
  if (reason === "cancelled")
450
1057
  return "CANCELLED";
451
- if (reason === "validation" || reason === "no-project" || reason.startsWith("config state:")) {
1058
+ if (reason === "validation" ||
1059
+ reason === "no-project" ||
1060
+ reason.startsWith("config state:") ||
1061
+ reason.startsWith("installed artifact targets") ||
1062
+ reason.startsWith("generate requires an installed")) {
452
1063
  return "VALIDATION_ERROR";
453
1064
  }
454
1065
  return "UNEXPECTED";
455
1066
  }
456
1067
  /** Generate proxy components from config. */
457
1068
  async function runGenerate(runtime, json, noTailwind, reconcileInstalled = false, target, yes = false) {
458
- const result = await runGenerateUseCase(commandContext(runtime, { json, yes }), {
459
- mkdirSync,
460
- writeFileSync,
461
- readFileSync,
462
- existsSync,
463
- renameSync,
464
- rmSync,
465
- readdirSync,
466
- copyFileSync,
467
- }, { noTailwind, reconcileInstalled, ...(target ? { target } : {}) });
1069
+ const context = commandContext(runtime, { json, yes });
1070
+ // W3-C: generation has no byte progress, so the strict task status covers it.
1071
+ const task = !json && runtime.readLine === undefined
1072
+ ? context.ui.task?.("Generating icon components", context.signal)
1073
+ : undefined;
1074
+ let result;
1075
+ try {
1076
+ // W3-C: generation has no byte progress, so its status is the task handle.
1077
+ result = await runGenerateUseCase(context, {
1078
+ mkdirSync,
1079
+ writeFileSync,
1080
+ readFileSync,
1081
+ existsSync,
1082
+ renameSync,
1083
+ rmSync,
1084
+ readdirSync,
1085
+ copyFileSync,
1086
+ }, { noTailwind, reconcileInstalled, ...(target ? { target } : {}) });
1087
+ }
1088
+ catch (error) {
1089
+ task?.fail(error instanceof Error ? error.message : String(error));
1090
+ throw error;
1091
+ }
468
1092
  if (!result.ok) {
1093
+ task?.fail(result.reason);
469
1094
  if (result.code === "TAILWIND_VERSION_UNSUPPORTED") {
470
1095
  if (json)
471
1096
  writeJson(runtime, jsonErrorBody("TAILWIND_VERSION_UNSUPPORTED", result.reason));
@@ -497,6 +1122,7 @@ async function runGenerate(runtime, json, noTailwind, reconcileInstalled = false
497
1122
  ...(result.warnings ? { warnings: result.warnings } : {}),
498
1123
  });
499
1124
  else {
1125
+ task?.succeed(`Generated ${result.files.length} files`);
500
1126
  runtime.stdout(`Generated ${result.files.length} files.\n`);
501
1127
  if (result.warnings) {
502
1128
  for (const warning of result.warnings)