@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
@@ -7,5 +7,5 @@ export interface ProjectLockIo {
7
7
  readonly now: () => number;
8
8
  readonly pid: number;
9
9
  }
10
- export declare function withProjectLock<T>(projectRoot: string, operation: "install" | "update" | "reload", work: () => Promise<T> | T, io?: ProjectLockIo): Promise<T>;
11
- export declare function withProjectLockSync<T>(projectRoot: string, operation: "install" | "update" | "reload", work: () => T, io?: ProjectLockIo): T;
10
+ export declare function withProjectLock<T>(projectRoot: string, operation: "install" | "update" | "reload" | "doctor" | "recover", work: () => Promise<T> | T, io?: ProjectLockIo): Promise<T>;
11
+ export declare function withProjectLockSync<T>(projectRoot: string, operation: "install" | "update" | "reload" | "doctor" | "recover", work: () => T, io?: ProjectLockIo): T;
@@ -15,8 +15,8 @@ const defaultIo = {
15
15
  process.kill(pid, 0);
16
16
  return true;
17
17
  }
18
- catch {
19
- return false;
18
+ catch (error) {
19
+ return error.code !== "ESRCH";
20
20
  } },
21
21
  now: Date.now,
22
22
  pid: process.pid,
@@ -27,7 +27,7 @@ export async function withProjectLock(projectRoot, operation, work, io = default
27
27
  let stale = false;
28
28
  try {
29
29
  const value = JSON.parse(io.read(path));
30
- stale = typeof value.pid === "number" && typeof value.createdAt === "number" && io.now() - value.createdAt > 30 * 60_000 && !io.isProcessAlive(value.pid);
30
+ stale = typeof value.pid === "number" && typeof value.createdAt === "number" && (operation === "recover" || io.now() - value.createdAt > 30 * 60_000) && !io.isProcessAlive(value.pid);
31
31
  }
32
32
  catch {
33
33
  stale = false;
@@ -56,7 +56,7 @@ export function withProjectLockSync(projectRoot, operation, work, io = defaultIo
56
56
  let stale = false;
57
57
  try {
58
58
  const value = JSON.parse(io.read(path));
59
- stale = typeof value.pid === "number" && typeof value.createdAt === "number" && io.now() - value.createdAt > 30 * 60_000 && !io.isProcessAlive(value.pid);
59
+ stale = typeof value.pid === "number" && typeof value.createdAt === "number" && (operation === "recover" || io.now() - value.createdAt > 30 * 60_000) && !io.isProcessAlive(value.pid);
60
60
  }
61
61
  catch {
62
62
  stale = false;
@@ -31,8 +31,8 @@ export type ContentInjectResult = {
31
31
  export declare function injectContentGlob(source: string, glob: string): ContentInjectResult;
32
32
  export declare function moeiconsContentGlob(outputDir: string, target?: "react" | "vue" | "vanilla" | "assets"): string;
33
33
  /**
34
- * Apply Tailwind content injection. Throws CliError(TAILWIND_VERSION_UNSUPPORTED)
35
- * for v4 when integration is requested.
34
+ * Apply Tailwind v3 content injection. Tailwind v4 discovers source files
35
+ * automatically, so generated components need no config rewrite.
36
36
  */
37
37
  export declare function planTailwindIntegration(projectRoot: string, outputDir: string, options: {
38
38
  readonly noTailwind: boolean;
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CliError } from "../errors/index.js";
4
3
  import { CLSX_VERSION_RANGE, TAILWIND_MERGE_VERSION_RANGE } from "../generator/cn.js";
5
4
  import { applyEdits, modify } from "jsonc-parser";
6
5
  const CONFIG_CANDIDATES = [
@@ -92,8 +91,8 @@ export function moeiconsContentGlob(outputDir, target) {
92
91
  return `./${normalized}/**/*.{${extensions}}`;
93
92
  }
94
93
  /**
95
- * Apply Tailwind content injection. Throws CliError(TAILWIND_VERSION_UNSUPPORTED)
96
- * for v4 when integration is requested.
94
+ * Apply Tailwind v3 content injection. Tailwind v4 discovers source files
95
+ * automatically, so generated components need no config rewrite.
97
96
  */
98
97
  export function planTailwindIntegration(projectRoot, outputDir, options) {
99
98
  if (options.noTailwind) {
@@ -107,7 +106,7 @@ export function planTailwindIntegration(projectRoot, outputDir, options) {
107
106
  return { files: [], notes: ["no Tailwind config found; skipped content injection"] };
108
107
  }
109
108
  if (detected.kind === "v4") {
110
- throw new CliError("TAILWIND_VERSION_UNSUPPORTED", "Tailwind CSS v4 was detected; this CLI release only auto-integrates Tailwind v3. Pass --no-tailwind to skip Tailwind config changes.");
109
+ return { files: [], notes: ["Tailwind CSS v4 detected; generated icons use explicit dimensions and need no config rewrite"] };
111
110
  }
112
111
  if (detected.kind === "unknown") {
113
112
  return {
@@ -1,10 +1,27 @@
1
+ /**
2
+ * Combined release archives contain all four target trees. The current Pro
3
+ * contract is roughly 60k regular files (9 groups × 554 icons), so the former
4
+ * 20k cap rejected valid official packages. The six-spec bitmap matrix expands
5
+ * to ~205 MiB (tar stream ~257 MiB), so the expanded ceiling covers that with
6
+ * headroom while staying finite for zip-bomb protection. `extractTarGz` enforces
7
+ * the same bound during gunzip via `maxOutputLength`, before the tar stream is
8
+ * materialised.
9
+ */
10
+ export declare const ICON_ARCHIVE_MAX_ENTRIES = 100000;
11
+ export declare const ICON_ARCHIVE_MAX_EXPANDED_BYTES: number;
1
12
  /** Build a gzipped POSIX tar from in-memory files (test fixtures and unpacking). */
2
13
  export declare function createTarGz(files: Readonly<Record<string, string | Uint8Array>>): Uint8Array;
3
14
  export type ExtractedTarFiles = {
4
15
  readonly files: Record<string, Uint8Array>;
5
16
  readonly errors: string[];
6
17
  };
7
- /** Gunzip + unpack tar; reject absolute paths, `..`, and oversized archives. */
18
+ /**
19
+ * Gunzip + unpack tar. Rejects absolute paths, `..`, symlinks and oversized
20
+ * archives. Path safety uses POSIX `/` separators (the tar format mandates `/`);
21
+ * a Windows-style separator would appear literally inside a name and never be
22
+ * treated as a path separator, so `..\evil` is inert (no traversal), which is
23
+ * the documented known assumption.
24
+ */
8
25
  export declare function extractTarGz(bytes: Uint8Array, limits: {
9
26
  maxEntries: number;
10
27
  maxExpandedBytes: number;
@@ -1,5 +1,22 @@
1
1
  import { gzipSync, gunzipSync } from "node:zlib";
2
2
  const BLOCK = 512;
3
+ /**
4
+ * Combined release archives contain all four target trees. The current Pro
5
+ * contract is roughly 60k regular files (9 groups × 554 icons), so the former
6
+ * 20k cap rejected valid official packages. The six-spec bitmap matrix expands
7
+ * to ~205 MiB (tar stream ~257 MiB), so the expanded ceiling covers that with
8
+ * headroom while staying finite for zip-bomb protection. `extractTarGz` enforces
9
+ * the same bound during gunzip via `maxOutputLength`, before the tar stream is
10
+ * materialised.
11
+ */
12
+ export const ICON_ARCHIVE_MAX_ENTRIES = 100_000;
13
+ export const ICON_ARCHIVE_MAX_EXPANDED_BYTES = 512 * 1024 * 1024;
14
+ /** True when a zlib gunzip failure means the bounded output was exceeded. */
15
+ function isOutputLimitError(error) {
16
+ const code = error?.code;
17
+ const message = error instanceof Error ? error.message : "";
18
+ return code === "ERR_BUFFER_TOO_LARGE" || /larger than|maxOutputLength|output length/i.test(message);
19
+ }
3
20
  function checksumHeader(header) {
4
21
  let sum = 0;
5
22
  for (let i = 0; i < BLOCK; i += 1)
@@ -48,15 +65,25 @@ function readOctal(buf, offset, length) {
48
65
  return 0;
49
66
  return Number.parseInt(raw, 8);
50
67
  }
51
- /** Gunzip + unpack tar; reject absolute paths, `..`, and oversized archives. */
68
+ /**
69
+ * Gunzip + unpack tar. Rejects absolute paths, `..`, symlinks and oversized
70
+ * archives. Path safety uses POSIX `/` separators (the tar format mandates `/`);
71
+ * a Windows-style separator would appear literally inside a name and never be
72
+ * treated as a path separator, so `..\evil` is inert (no traversal), which is
73
+ * the documented known assumption.
74
+ */
52
75
  export function extractTarGz(bytes, limits) {
53
76
  const files = {};
54
77
  const errors = [];
55
78
  let data;
56
79
  try {
57
- data = gunzipSync(bytes);
80
+ // Bound the decompressed stream itself so a zip bomb fails during gunzip,
81
+ // before the tar bytes are materialised.
82
+ data = gunzipSync(bytes, { maxOutputLength: limits.maxExpandedBytes });
58
83
  }
59
- catch {
84
+ catch (error) {
85
+ if (isOutputLimitError(error))
86
+ return { files, errors: ["expanded size exceeds limit"] };
60
87
  return { files, errors: ["invalid gzip"] };
61
88
  }
62
89
  let offset = 0;
@@ -73,6 +100,12 @@ export function extractTarGz(bytes, limits) {
73
100
  const padded = Math.ceil(size / BLOCK) * BLOCK;
74
101
  const content = data.subarray(offset, offset + size);
75
102
  offset += padded;
103
+ if (type === 0x31 || type === 0x32) {
104
+ // Hard-link (typeflag '1') and symlink (typeflag '2') entries are
105
+ // rejected with an explicit error, never silently skipped.
106
+ errors.push(`link entries are not allowed: ${name || "<unnamed>"}`);
107
+ continue;
108
+ }
76
109
  if (type !== 0 && type !== 0x30)
77
110
  continue;
78
111
  if (!name || name.endsWith("/"))
@@ -86,6 +119,10 @@ export function extractTarGz(bytes, limits) {
86
119
  errors.push(`unsafe path "${name}"`);
87
120
  continue;
88
121
  }
122
+ if (name in files) {
123
+ errors.push(`duplicate entry "${name}"`);
124
+ continue;
125
+ }
89
126
  expanded += content.byteLength;
90
127
  if (expanded > limits.maxExpandedBytes) {
91
128
  errors.push("expanded size exceeds limit");
@@ -0,0 +1,62 @@
1
+ import type { UiTheme } from "../ui/theme.js";
2
+ /**
3
+ * W3-A/C/D/E: pure TUI component renderers.
4
+ *
5
+ * Kept dependency-free (no stream/Clack/process access) so every frame is unit
6
+ * testable and the adapters (`ui/clack.ts`, `ui/stream.ts`,
7
+ * `ui/non-interactive.ts`) only own I/O. Colour is always supplied through the
8
+ * shared `UiTheme` (white / brand blue / brand red + a dim intensity), never a
9
+ * hardcoded escape, and every renderer degrades to plain text when colour is
10
+ * disabled or the output is not a TTY.
11
+ */
12
+ export declare const PROGRESS_GLYPHS: Readonly<{
13
+ filled: "█";
14
+ empty: "░";
15
+ }>;
16
+ /** W3-C running animation frames. Deterministic by frame index. */
17
+ export declare const TASK_RUNNING_FRAMES: readonly string[];
18
+ export type TaskState = "running" | "success" | "failure" | "timeout";
19
+ export type HistoryState = "running" | "success" | "failure" | "timeout";
20
+ export declare function formatRate(bytes: number, elapsedMs: number): string | undefined;
21
+ export interface ProgressBarInput {
22
+ readonly label: string;
23
+ readonly done?: number;
24
+ readonly total?: number;
25
+ readonly detail?: string;
26
+ readonly columns?: number;
27
+ readonly theme: UiTheme;
28
+ /** Elapsed wall time in ms; enables the rate suffix. */
29
+ readonly elapsedMs?: number;
30
+ /** Fixed bar width override (tests); otherwise derived from `columns`. */
31
+ readonly barWidth?: number;
32
+ }
33
+ /**
34
+ * W3-A: one-line progress bar with percentage, transferred/total bytes, an
35
+ * optional rate and a detail suffix. Unknown total renders a byte-only line
36
+ * (never a fake percentage). `columns <= 0` (non-TTY) collapses to a log line.
37
+ */
38
+ export declare function renderProgressBar(input: ProgressBarInput): string;
39
+ export interface TaskStatusInput {
40
+ readonly state: TaskState;
41
+ readonly label: string;
42
+ readonly detail?: string;
43
+ readonly frame?: number;
44
+ readonly theme: UiTheme;
45
+ }
46
+ /** W3-C: strict four-state task status with a lightweight running animation. */
47
+ export declare function renderTaskStatus(input: TaskStatusInput): string;
48
+ export interface KeyHint {
49
+ readonly key: string;
50
+ readonly label: string;
51
+ }
52
+ export declare const SELECT_KEY_HINTS: readonly KeyHint[];
53
+ export declare const CONFIRM_KEY_HINTS: readonly KeyHint[];
54
+ /** W3-D: low-opacity, small key hints for a pure-keyboard workflow. */
55
+ export declare function renderKeyHints(hints: readonly KeyHint[], theme: UiTheme): string;
56
+ export interface HistoryEntry {
57
+ readonly label: string;
58
+ readonly state: HistoryState;
59
+ readonly detail?: string;
60
+ }
61
+ /** W3-E2: single-line recent-task preview with colour-coded state. */
62
+ export declare function renderHistoryBar(entry: HistoryEntry | undefined, theme: UiTheme): string | undefined;
@@ -0,0 +1,100 @@
1
+ import { formatBytes } from "../metadata/version.js";
2
+ import { visibleWidth } from "../ui/banner.js";
3
+ /**
4
+ * W3-A/C/D/E: pure TUI component renderers.
5
+ *
6
+ * Kept dependency-free (no stream/Clack/process access) so every frame is unit
7
+ * testable and the adapters (`ui/clack.ts`, `ui/stream.ts`,
8
+ * `ui/non-interactive.ts`) only own I/O. Colour is always supplied through the
9
+ * shared `UiTheme` (white / brand blue / brand red + a dim intensity), never a
10
+ * hardcoded escape, and every renderer degrades to plain text when colour is
11
+ * disabled or the output is not a TTY.
12
+ */
13
+ export const PROGRESS_GLYPHS = Object.freeze({
14
+ filled: "█",
15
+ empty: "░",
16
+ });
17
+ /** W3-C running animation frames. Deterministic by frame index. */
18
+ export const TASK_RUNNING_FRAMES = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
19
+ function clamp(value, min, max) {
20
+ return Math.min(Math.max(value, min), max);
21
+ }
22
+ export function formatRate(bytes, elapsedMs) {
23
+ if (!Number.isFinite(bytes) || !Number.isFinite(elapsedMs) || elapsedMs <= 0 || bytes <= 0)
24
+ return undefined;
25
+ const perSecond = bytes / (elapsedMs / 1000);
26
+ return `${formatBytes(Math.round(perSecond))}/s`;
27
+ }
28
+ function progressBarWidth(columns, labelWidth, suffixWidth) {
29
+ const available = columns - labelWidth - suffixWidth - 4;
30
+ return clamp(available, 8, 40);
31
+ }
32
+ /**
33
+ * W3-A: one-line progress bar with percentage, transferred/total bytes, an
34
+ * optional rate and a detail suffix. Unknown total renders a byte-only line
35
+ * (never a fake percentage). `columns <= 0` (non-TTY) collapses to a log line.
36
+ */
37
+ export function renderProgressBar(input) {
38
+ const { theme, label } = input;
39
+ const done = Math.max(0, input.done ?? 0);
40
+ const total = input.total !== undefined && input.total > 0 ? input.total : undefined;
41
+ const columns = input.columns ?? 80;
42
+ const detail = input.detail ? ` ${theme.dim(input.detail)}` : "";
43
+ const rate = input.elapsedMs !== undefined ? formatRate(done, input.elapsedMs) : undefined;
44
+ if (columns <= 0) {
45
+ const suffix = total !== undefined ? `${formatBytes(done)}/${formatBytes(total)}` : formatBytes(done);
46
+ return `${label} ${suffix}${rate ? ` (${rate})` : ""}${input.detail ? ` ${input.detail}` : ""}`;
47
+ }
48
+ if (total === undefined) {
49
+ return `${label} ${formatBytes(done)}${rate ? ` (${rate})` : ""}${detail}`;
50
+ }
51
+ const ratio = clamp(done / total, 0, 1);
52
+ const percent = Math.floor(ratio * 100);
53
+ const suffix = `${String(percent).padStart(3, " ")}% ${formatBytes(done)}/${formatBytes(total)}${rate ? ` ${rate}` : ""}`;
54
+ // Budget by VISIBLE width: the label/suffix may carry ANSI (e.g. a dim rate),
55
+ // which must not eat into the bar's column budget.
56
+ const width = input.barWidth ?? progressBarWidth(columns, visibleWidth(label), visibleWidth(suffix));
57
+ const filled = Math.round(width * ratio);
58
+ const bar = `${theme.blue(PROGRESS_GLYPHS.filled.repeat(filled))}${theme.dim(PROGRESS_GLYPHS.empty.repeat(width - filled))}`;
59
+ return `${label} ${bar} ${suffix}${detail}`;
60
+ }
61
+ /** W3-C: strict four-state task status with a lightweight running animation. */
62
+ export function renderTaskStatus(input) {
63
+ const { theme, state, label } = input;
64
+ const detail = input.detail ? ` ${theme.dim(input.detail)}` : "";
65
+ if (state === "success")
66
+ return `${theme.blue(theme.symbols.success)} ${label}${detail}`;
67
+ if (state === "failure")
68
+ return `${theme.red(theme.symbols.failure)} ${label}${detail}`;
69
+ if (state === "timeout")
70
+ return `${theme.red(theme.symbols.timeout)} ${label}${detail}`;
71
+ const frame = TASK_RUNNING_FRAMES[Math.abs(input.frame ?? 0) % TASK_RUNNING_FRAMES.length] ?? "…";
72
+ return `${theme.blue(frame)} ${label}${detail}`;
73
+ }
74
+ export const SELECT_KEY_HINTS = Object.freeze([
75
+ { key: "↑/↓", label: "move" },
76
+ { key: "1-9", label: "select" },
77
+ { key: "Enter", label: "submit" },
78
+ { key: "Esc", label: "cancel" },
79
+ ]);
80
+ export const CONFIRM_KEY_HINTS = Object.freeze([
81
+ { key: "Y/N", label: "choose" },
82
+ { key: "Enter", label: "submit" },
83
+ { key: "Esc", label: "cancel" },
84
+ ]);
85
+ /** W3-D: low-opacity, small key hints for a pure-keyboard workflow. */
86
+ export function renderKeyHints(hints, theme) {
87
+ return theme.dim(hints.map((hint) => `${hint.key} ${hint.label}`).join(" · "));
88
+ }
89
+ /** W3-E2: single-line recent-task preview with colour-coded state. */
90
+ export function renderHistoryBar(entry, theme) {
91
+ if (!entry)
92
+ return undefined;
93
+ if (entry.state === "success")
94
+ return `${theme.blue(theme.symbols.success)} ${entry.label}${entry.detail ? ` ${theme.dim(entry.detail)}` : ""}`;
95
+ if (entry.state === "failure")
96
+ return `${theme.red(theme.symbols.failure)} ${entry.label}${entry.detail ? ` ${theme.dim(entry.detail)}` : ""}`;
97
+ if (entry.state === "timeout")
98
+ return `${theme.red(theme.symbols.timeout)} ${entry.label}${entry.detail ? ` ${theme.dim(entry.detail)}` : ""}`;
99
+ return `${theme.dim(theme.symbols.pending)} ${entry.label}${entry.detail ? ` ${theme.dim(entry.detail)}` : ""}`;
100
+ }
@@ -18,6 +18,7 @@ export interface TuiDeps {
18
18
  export type Choice<T> = {
19
19
  readonly value: T;
20
20
  readonly label: string;
21
+ readonly separatorBefore?: boolean;
21
22
  };
22
23
  export interface SelectOptions<T> {
23
24
  readonly choices: readonly Choice<T>[];
@@ -14,6 +14,10 @@ export function renderBanner(deps, banner) {
14
14
  deps.streams.write(banner);
15
15
  deps.streams.write("\n");
16
16
  }
17
+ function formatIndexedLabel(index, total, label) {
18
+ const width = String(Math.max(total, 1)).length;
19
+ return `${String(index + 1).padStart(width, " ")}. ${label}`;
20
+ }
17
21
  /** Interactive single-choice selection; non-TTY rejects with guidance. */
18
22
  export async function select(deps, prompt, options) {
19
23
  assertNotAborted(deps);
@@ -21,8 +25,11 @@ export async function select(deps, prompt, options) {
21
25
  throw new CliError("NOT_TTY", "interactive selection requires a TTY; use a specific command or --json");
22
26
  }
23
27
  deps.streams.write(`${prompt}\n`);
28
+ const total = options.choices.length;
24
29
  options.choices.forEach((choice, i) => {
25
- deps.streams.write(` ${i + 1}) ${choice.label}\n`);
30
+ if (choice.separatorBefore)
31
+ deps.streams.write("\n");
32
+ deps.streams.write(` ${formatIndexedLabel(i, total, choice.label)}\n`);
26
33
  });
27
34
  if (options.canCancel)
28
35
  deps.streams.write(" 0) Cancel\n");
@@ -1,5 +1,12 @@
1
- export declare const MOEICONS_BANNER: string;
2
- export declare const CLI_NOTICE_LINES: readonly ["Run moeicons from your project root.", "React/Vue targets require the matching framework.", "Automatic Tailwind integration supports v3 only.", "Vanilla and Assets do not require React or Vue."];
1
+ import { MOEICONS_BANNER } from "./generated/wordmark.js";
2
+ import { type UiTheme } from "./theme.js";
3
+ export { MOEICONS_BANNER };
4
+ export { MOEICONS_WORDMARK_LARGE, WORDMARK_LARGE_GLYPHS, WORDMARK_LARGE_WIDTH, } from "./generated/wordmark-large.js";
5
+ /** Figlet / mid tier when columns are in [52, 101). */
6
+ export declare const MIN_FIGLET_COLUMNS = 52;
7
+ /** Large Unicode wordmark when columns >= 101. */
8
+ export declare const MIN_LARGE_COLUMNS = 101;
9
+ export declare const CLI_NOTICE_LINES: readonly ["Run `moeicons` from your project root.", "React/Vue targets require the matching framework.", "Automatic Tailwind integration supports v3 only.", "Vanilla and Assets do not require React or Vue."];
3
10
  /** Visible terminal width: ANSI sequences and combining marks take no columns. */
4
11
  export declare function visibleWidth(value: string): number;
5
12
  /** Render an adaptive notice box without splitting ANSI escapes or Unicode characters. */
@@ -7,5 +14,26 @@ export declare function renderNoticeBox(message: string | readonly string[], opt
7
14
  readonly width?: number;
8
15
  readonly unicode?: boolean;
9
16
  }): string;
10
- export declare function renderBannerText(): string;
17
+ export interface RenderBannerOptions {
18
+ readonly columns: number;
19
+ readonly color: boolean;
20
+ }
21
+ export type WordmarkTier = "large" | "figlet" | "single";
22
+ /** Left-pad lines onto a canvas. Never adds trailing spaces. */
23
+ export declare function centerLines(lines: readonly string[], width?: number): string[];
24
+ export declare function selectWordmarkTier(columns: number): WordmarkTier;
25
+ /**
26
+ * Paint ░ runs with brand blue/red by frozen glyph intervals.
27
+ * ██ keep the default foreground. Disable color with theme.enabled=false.
28
+ */
29
+ export declare function paintWordmarkLarge(lines: readonly string[], theme: UiTheme): string[];
30
+ /** Wordmark only (no notices). Used by wizard startup ordering. */
31
+ export declare function renderWordmarkText(options: RenderBannerOptions): string;
32
+ /** Project-root / dependency notice box for the current terminal width. */
33
+ export declare function renderProjectNotice(columns: number): string;
34
+ /**
35
+ * Combined wordmark + project notice.
36
+ * Wizard prefers renderWordmarkText then version/update then renderProjectNotice.
37
+ */
38
+ export declare function renderBannerText(options: RenderBannerOptions): string;
11
39
  export declare const CLI_VERSION: string;
package/dist/ui/banner.js CHANGED
@@ -1,34 +1,33 @@
1
1
  import packageJson from "../../package.json" with { type: "json" };
2
- /** ASCII banner generated by scripts/generate-banner.mjs (figlet, Standard font). */
3
- const slash = String.fromCharCode(92);
4
- export const MOEICONS_BANNER = [
5
- "",
6
- " __ __ ___ _____ ___ ____ ___ _ _ ____",
7
- ` | ${slash}/ |/ _ ${slash}| ____|_ _/ ___/ _ ${slash}| ${slash} | / ___|`,
8
- " | |" + slash + "/| | | | | _| | | | | | | " + slash + "| " + slash + "___ " + slash,
9
- ` | | | | |_| | |___ | | |__| |_| | |${slash} |___) |`,
10
- ` |_| |_|${slash}___/|_____|___${slash}____${slash}___/|_| ${slash}_|____/`,
11
- "",
12
- ].join("\n");
2
+ import { MOEICONS_BANNER } from "./generated/wordmark.js";
3
+ import { MOEICONS_WORDMARK_LARGE, WORDMARK_LARGE_GLYPHS, WORDMARK_LARGE_WIDTH, } from "./generated/wordmark-large.js";
4
+ import { ANSI_FG_RESET, createTheme } from "./theme.js";
5
+ export { MOEICONS_BANNER };
6
+ export { MOEICONS_WORDMARK_LARGE, WORDMARK_LARGE_GLYPHS, WORDMARK_LARGE_WIDTH, } from "./generated/wordmark-large.js";
7
+ const CANVAS_WIDTH = 47;
8
+ /** Figlet / mid tier when columns are in [52, 101). */
9
+ export const MIN_FIGLET_COLUMNS = 52;
10
+ /** Large Unicode wordmark when columns >= 101. */
11
+ export const MIN_LARGE_COLUMNS = WORDMARK_LARGE_WIDTH;
13
12
  const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
14
13
  const COMBINING_MARK = /\p{Mark}/u;
15
14
  const CONTROL = /[\p{Cc}\p{Cf}]/u;
16
15
  const WIDE_CODEPOINT = "[" +
17
- "\u1100-\u115f" + // Hangul jamo
18
- "\u2e80-\u303e" + // CJK radicals, Kangxi, punctuation
19
- "\u3041-\u33ff" + // Hiragana/Katakana, CJK symbols, enclosed
20
- "\u3400-\u4dbf" + // CJK ext A
21
- "\u4e00-\u9fff" + // CJK unified
22
- "\ua000-\ua4cf" + // Yi
23
- "\uac00-\ud7a3" + // Hangul syllables
24
- "\uf900-\ufaff" + // CJK compat
25
- "\ufe30-\ufe4f" + // CJK compat forms
26
- "\uff00-\uff60" + // fullwidth forms
27
- "\uffe0-\uffe6" + // fullwidth signs
16
+ "\u1100-\u115f" +
17
+ "\u2e80-\u303e" +
18
+ "\u3041-\u33ff" +
19
+ "\u3400-\u4dbf" +
20
+ "\u4e00-\u9fff" +
21
+ "\ua000-\ua4cf" +
22
+ "\uac00-\ud7a3" +
23
+ "\uf900-\ufaff" +
24
+ "\ufe30-\ufe4f" +
25
+ "\uff00-\uff60" +
26
+ "\uffe0-\uffe6" +
28
27
  "]";
29
28
  const WIDE_CHAR = new RegExp(WIDE_CODEPOINT, "u");
30
29
  export const CLI_NOTICE_LINES = [
31
- "Run moeicons from your project root.",
30
+ "Run `moeicons` from your project root.",
32
31
  "React/Vue targets require the matching framework.",
33
32
  "Automatic Tailwind integration supports v3 only.",
34
33
  "Vanilla and Assets do not require React or Vue.",
@@ -99,7 +98,103 @@ export function renderNoticeBox(message, options = {}) {
99
98
  `${bottom}${horizontal.repeat(contentWidth + 2)}${bottomRight}`,
100
99
  ].join("\n");
101
100
  }
102
- export function renderBannerText() {
103
- return `${MOEICONS_BANNER}\n${renderNoticeBox(CLI_NOTICE_LINES)}\n`;
101
+ function normalizeColumns(columns) {
102
+ if (!Number.isFinite(columns) || columns <= 0)
103
+ return 80;
104
+ return columns;
105
+ }
106
+ function contentLines(value) {
107
+ const lines = value.split("\n");
108
+ while (lines[0] === "")
109
+ lines.shift();
110
+ while (lines.length > 0 && lines[lines.length - 1] === "")
111
+ lines.pop();
112
+ return lines;
113
+ }
114
+ /** Left-pad lines onto a canvas. Never adds trailing spaces. */
115
+ export function centerLines(lines, width = CANVAS_WIDTH) {
116
+ return lines.map((line) => {
117
+ const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
118
+ return pad > 0 ? `${" ".repeat(pad)}${line}` : line;
119
+ });
120
+ }
121
+ export function selectWordmarkTier(columns) {
122
+ const normalized = normalizeColumns(columns);
123
+ if (normalized >= MIN_LARGE_COLUMNS)
124
+ return "large";
125
+ if (normalized >= MIN_FIGLET_COLUMNS)
126
+ return "figlet";
127
+ return "single";
128
+ }
129
+ function glyphShadeAt(column) {
130
+ for (const glyph of WORDMARK_LARGE_GLYPHS) {
131
+ if (column >= glyph.start && column < glyph.end)
132
+ return glyph.shade;
133
+ }
134
+ return undefined;
135
+ }
136
+ /**
137
+ * Paint ░ runs with brand blue/red by frozen glyph intervals.
138
+ * ██ keep the default foreground. Disable color with theme.enabled=false.
139
+ */
140
+ export function paintWordmarkLarge(lines, theme) {
141
+ // Color open sequences come only from theme (no RGB/ANSI literals here).
142
+ const openBlue = theme.openBlue;
143
+ const openRed = theme.openRed;
144
+ return lines.map((line) => {
145
+ const padded = line.padEnd(WORDMARK_LARGE_WIDTH, " ");
146
+ if (!theme.enabled)
147
+ return padded.replace(/ +$/u, "");
148
+ let result = "";
149
+ let active;
150
+ for (let column = 0; column < WORDMARK_LARGE_WIDTH; column += 1) {
151
+ const character = padded[column] ?? " ";
152
+ if (character === "░") {
153
+ const shade = glyphShadeAt(column) ?? "blue";
154
+ if (active !== shade) {
155
+ if (active !== undefined)
156
+ result += ANSI_FG_RESET;
157
+ result += shade === "blue" ? openBlue : openRed;
158
+ active = shade;
159
+ }
160
+ result += character;
161
+ }
162
+ else {
163
+ if (active !== undefined) {
164
+ result += ANSI_FG_RESET;
165
+ active = undefined;
166
+ }
167
+ result += character;
168
+ }
169
+ }
170
+ if (active !== undefined)
171
+ result += ANSI_FG_RESET;
172
+ return result.replace(/ +$/u, "");
173
+ });
174
+ }
175
+ /** Wordmark only (no notices). Used by wizard startup ordering. */
176
+ export function renderWordmarkText(options) {
177
+ const columns = normalizeColumns(options.columns);
178
+ const tier = selectWordmarkTier(columns);
179
+ if (tier === "single")
180
+ return "MOEICONS";
181
+ if (tier === "figlet")
182
+ return centerLines(contentLines(MOEICONS_BANNER)).join("\n");
183
+ const theme = createTheme(options.color);
184
+ return paintWordmarkLarge([...MOEICONS_WORDMARK_LARGE], theme).join("\n");
185
+ }
186
+ /** Project-root / dependency notice box for the current terminal width. */
187
+ export function renderProjectNotice(columns) {
188
+ return renderNoticeBox(CLI_NOTICE_LINES, { width: Math.max(20, normalizeColumns(columns)) });
189
+ }
190
+ /**
191
+ * Combined wordmark + project notice.
192
+ * Wizard prefers renderWordmarkText then version/update then renderProjectNotice.
193
+ */
194
+ export function renderBannerText(options) {
195
+ const columns = normalizeColumns(options.columns);
196
+ const wordmark = renderWordmarkText(options);
197
+ const notice = renderProjectNotice(columns);
198
+ return `${wordmark}\n${notice}\n`;
104
199
  }
105
200
  export const CLI_VERSION = packageJson.version;
@@ -0,0 +1,41 @@
1
+ import { isCancel } from "@clack/core";
2
+ import type { Readable, Writable } from "node:stream";
3
+ import type { UiChoice } from "../core/context.js";
4
+ import type { UiTheme } from "./theme.js";
5
+ export { isCancel };
6
+ export interface BrandedPromptIo {
7
+ readonly theme: UiTheme;
8
+ readonly input?: Readable;
9
+ readonly output?: Writable;
10
+ readonly signal?: AbortSignal;
11
+ }
12
+ export interface SelectFrameOption {
13
+ readonly value: string;
14
+ readonly label: string;
15
+ readonly separatorBefore?: boolean;
16
+ }
17
+ export interface SelectFrameState {
18
+ readonly state: string;
19
+ readonly cursor: number;
20
+ readonly options: readonly SelectFrameOption[];
21
+ }
22
+ export interface ConfirmFrameState {
23
+ readonly state: string;
24
+ readonly value: boolean;
25
+ }
26
+ /** W3-D: opt-in key hints so existing golden output stays stable. */
27
+ export interface FrameRenderOptions {
28
+ readonly hints?: boolean;
29
+ }
30
+ /** Format `1. label` with stable width for lists of 10+. */
31
+ export declare function formatChoiceNumber(index: number, total: number, label: string): string;
32
+ /** Renders every option with 1-based numbers. Back separators are blank lines. */
33
+ export declare function renderSelectFrame(prompt: SelectFrameState, message: string, theme: UiTheme, options?: FrameRenderOptions): string;
34
+ export declare function renderConfirmFrame(prompt: ConfirmFrameState, message: string, theme: UiTheme, options?: FrameRenderOptions): string;
35
+ export declare function brandedSelect(options: BrandedPromptIo & {
36
+ readonly message: string;
37
+ readonly choices: readonly UiChoice[];
38
+ }): Promise<string | symbol>;
39
+ export declare function brandedConfirm(options: BrandedPromptIo & {
40
+ readonly message: string;
41
+ }): Promise<boolean | symbol>;