@moewolf/moe-icons-cli 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +243 -9
  2. package/bin/check-node.cjs +36 -0
  3. package/bin/moeicons.js +12 -1
  4. package/dist/api/client.d.ts +13 -2
  5. package/dist/api/client.js +145 -21
  6. package/dist/auth/device-login.d.ts +10 -7
  7. package/dist/auth/device-login.js +60 -37
  8. package/dist/auth/login-schemas.d.ts +24 -0
  9. package/dist/auth/login-schemas.js +67 -0
  10. package/dist/catalog/catalog.d.ts +11 -0
  11. package/dist/catalog/catalog.js +43 -0
  12. package/dist/catalog/catalog.json +538 -3395
  13. package/dist/cli.d.ts +11 -2
  14. package/dist/cli.js +773 -147
  15. package/dist/commands/parser.d.ts +13 -2
  16. package/dist/commands/parser.js +138 -50
  17. package/dist/config-package/generated/SOURCE.json +25 -0
  18. package/dist/config-package/generated/moeicons-config.schema.json +191 -0
  19. package/dist/config-package/generated/moeicons.config.jsonc +42 -0
  20. package/dist/config-package/generated/render-config.cjs +145 -0
  21. package/dist/config-package/generated/validate-config.cjs +293 -0
  22. package/dist/config-package/generated-config.d.ts +32 -0
  23. package/dist/config-package/generated-config.js +19 -0
  24. package/dist/core/auth.d.ts +24 -0
  25. package/dist/core/auth.js +127 -8
  26. package/dist/core/bitmap-assets.d.ts +19 -5
  27. package/dist/core/bitmap-assets.js +40 -31
  28. package/dist/core/bitmap-shard-budget.d.ts +43 -0
  29. package/dist/core/bitmap-shard-budget.js +16 -0
  30. package/dist/core/bitmap-shard-cache.d.ts +68 -0
  31. package/dist/core/bitmap-shard-cache.js +115 -0
  32. package/dist/core/bitmap-shard-download.d.ts +130 -0
  33. package/dist/core/bitmap-shard-download.js +359 -0
  34. package/dist/core/bitmap-shard-resolver.d.ts +129 -0
  35. package/dist/core/bitmap-shard-resolver.js +262 -0
  36. package/dist/core/bitmap-shards.d.ts +70 -0
  37. package/dist/core/bitmap-shards.js +249 -0
  38. package/dist/core/bootstrap.d.ts +37 -0
  39. package/dist/core/bootstrap.js +54 -0
  40. package/dist/core/cache.d.ts +34 -0
  41. package/dist/core/cache.js +96 -0
  42. package/dist/core/context.d.ts +26 -0
  43. package/dist/core/doctor.d.ts +48 -0
  44. package/dist/core/doctor.js +190 -0
  45. package/dist/core/free-download.d.ts +64 -6
  46. package/dist/core/free-download.js +402 -30
  47. package/dist/core/generate.d.ts +29 -0
  48. package/dist/core/generate.js +341 -65
  49. package/dist/core/icon-selection.d.ts +8 -0
  50. package/dist/core/icon-selection.js +19 -0
  51. package/dist/core/install.d.ts +14 -0
  52. package/dist/core/install.js +132 -13
  53. package/dist/core/library-update.d.ts +5 -1
  54. package/dist/core/library-update.js +176 -17
  55. package/dist/core/local-test-env.d.ts +24 -0
  56. package/dist/core/local-test-env.js +43 -0
  57. package/dist/core/metadata-sync.d.ts +30 -0
  58. package/dist/core/metadata-sync.js +97 -0
  59. package/dist/core/pro-download.d.ts +63 -0
  60. package/dist/core/pro-download.js +247 -27
  61. package/dist/core/pro-install.d.ts +11 -3
  62. package/dist/core/pro-install.js +181 -19
  63. package/dist/core/pro-resources.d.ts +79 -0
  64. package/dist/core/pro-resources.js +204 -0
  65. package/dist/core/pro-selected-download.d.ts +18 -0
  66. package/dist/core/pro-selected-download.js +269 -0
  67. package/dist/core/release-descriptor.d.ts +42 -1
  68. package/dist/core/release-descriptor.js +117 -9
  69. package/dist/core/resource-variant.d.ts +27 -10
  70. package/dist/core/resource-variant.js +75 -34
  71. package/dist/core/selected-resources.d.ts +59 -0
  72. package/dist/core/selected-resources.js +350 -0
  73. package/dist/core/signed-artifact.d.ts +8 -1
  74. package/dist/core/signed-artifact.js +35 -11
  75. package/dist/core/target-subtree.d.ts +8 -0
  76. package/dist/core/target-subtree.js +54 -3
  77. package/dist/core/version-service.d.ts +11 -0
  78. package/dist/core/version-service.js +129 -24
  79. package/dist/core/wizard.d.ts +20 -17
  80. package/dist/core/wizard.js +66 -28
  81. package/dist/errors/errors.d.ts +2 -1
  82. package/dist/errors/errors.js +1 -0
  83. package/dist/generator/generate.d.ts +1 -1
  84. package/dist/generator/generate.js +420 -213
  85. package/dist/generator/shared/SOURCE.json +7 -0
  86. package/dist/generator/shared/svg-model.cjs +156 -0
  87. package/dist/generator/svg-model.d.ts +16 -0
  88. package/dist/generator/svg-model.js +4 -0
  89. package/dist/generator/theme-resolve.d.ts +2 -0
  90. package/dist/generator/theme-resolve.js +57 -26
  91. package/dist/mcp/server.d.ts +23 -36
  92. package/dist/mcp/server.js +74 -152
  93. package/dist/mcp/services.d.ts +4 -0
  94. package/dist/mcp/services.js +230 -0
  95. package/dist/metadata/archive.d.ts +38 -0
  96. package/dist/metadata/archive.js +94 -0
  97. package/dist/metadata/manifest.d.ts +33 -0
  98. package/dist/metadata/manifest.js +136 -0
  99. package/dist/metadata/version.d.ts +22 -0
  100. package/dist/metadata/version.js +53 -0
  101. package/dist/project/anchors/application.d.ts +13 -0
  102. package/dist/project/anchors/application.js +377 -0
  103. package/dist/project/anchors/apply.d.ts +28 -0
  104. package/dist/project/anchors/apply.js +65 -0
  105. package/dist/project/anchors/diagnose.d.ts +26 -0
  106. package/dist/project/anchors/diagnose.js +90 -0
  107. package/dist/project/anchors/helpers.d.ts +20 -0
  108. package/dist/project/anchors/helpers.js +55 -0
  109. package/dist/project/anchors/moeicons-config.d.ts +21 -0
  110. package/dist/project/anchors/moeicons-config.js +83 -0
  111. package/dist/project/anchors/parse.d.ts +14 -0
  112. package/dist/project/anchors/parse.js +36 -0
  113. package/dist/project/anchors/project-manifest.d.ts +19 -0
  114. package/dist/project/anchors/project-manifest.js +109 -0
  115. package/dist/project/anchors/style.d.ts +12 -0
  116. package/dist/project/anchors/style.js +87 -0
  117. package/dist/project/anchors/types.d.ts +32 -0
  118. package/dist/project/anchors/types.js +9 -0
  119. package/dist/project/config.d.ts +59 -5
  120. package/dist/project/config.js +234 -102
  121. package/dist/project/install-metadata.d.ts +23 -2
  122. package/dist/project/install-metadata.js +58 -7
  123. package/dist/project/install.d.ts +24 -4
  124. package/dist/project/install.js +402 -190
  125. package/dist/project/project-lock.d.ts +2 -2
  126. package/dist/project/project-lock.js +4 -4
  127. package/dist/project/tailwind.d.ts +2 -2
  128. package/dist/project/tailwind.js +3 -4
  129. package/dist/project/tar-gz.d.ts +18 -1
  130. package/dist/project/tar-gz.js +40 -3
  131. package/dist/tui/components.d.ts +62 -0
  132. package/dist/tui/components.js +100 -0
  133. package/dist/tui/primitives.d.ts +1 -0
  134. package/dist/tui/primitives.js +8 -1
  135. package/dist/ui/banner.d.ts +31 -3
  136. package/dist/ui/banner.js +120 -25
  137. package/dist/ui/branded-prompts.d.ts +41 -0
  138. package/dist/ui/branded-prompts.js +80 -0
  139. package/dist/ui/clack.d.ts +6 -3
  140. package/dist/ui/clack.js +56 -5
  141. package/dist/ui/create-ui.d.ts +1 -0
  142. package/dist/ui/create-ui.js +5 -1
  143. package/dist/ui/generated/wordmark-large.d.ts +17 -0
  144. package/dist/ui/generated/wordmark-large.js +28 -0
  145. package/dist/ui/generated/wordmark.d.ts +2 -0
  146. package/dist/ui/generated/wordmark.js +2 -0
  147. package/dist/ui/non-interactive.js +8 -0
  148. package/dist/ui/stream.js +30 -0
  149. package/dist/ui/theme.d.ts +42 -0
  150. package/dist/ui/theme.js +59 -0
  151. package/package.json +14 -6
package/README.md CHANGED
@@ -1,32 +1,266 @@
1
1
  # moeicons
2
2
 
3
3
  Public CLI for the Moeicons icon library. Installs icon style groups into your
4
- project, manages login/tokens, generates React/Vue proxy components, and exposes
5
- an MCP server for AI clients.
4
+ project, manages login/tokens, generates React/Vue proxy components, syncs the
5
+ AI manual + icon catalog, and exposes an MCP server for AI clients.
6
6
 
7
7
  ## Commands
8
8
 
9
9
  ```text
10
10
  moeicons interactive guided flow (free / pro / login)
11
- moeicons install [group] install an icon group
11
+ moeicons install [group] install an icon group (free | pro)
12
12
  moeicons login browser login (PKCE)
13
13
  moeicons logout clear local session
14
14
  moeicons account show account/tier info
15
15
  moeicons groups list available icon groups
16
- moeicons generate generate target-specific project files
16
+ moeicons generate generate target-specific project files
17
+ moeicons init create moeicons.config.json
18
+ moeicons update metadata sync MANUAL.md/catalog.json/manifest.json only
19
+ moeicons update full code + metadata update
17
20
  moeicons mcp start the MCP stdio server
18
- npm install -g @moewolf/moe-icons-cli
19
- moeicons --version show version
21
+ moeicons --version show version
20
22
  moeicons --help show help
21
23
  ```
22
24
 
25
+ ## Requirements
26
+
27
+ Node.js **22 or later**. Node 20 reached end-of-life on 2026-03-24 and is not
28
+ on the supported line (no security patches). The CLI fails fast with an install
29
+ link instead of a `SyntaxError` if the runtime is too old.
30
+
31
+ Primary development and CI use Node 24. See https://nodejs.org/en/about/eol
32
+
33
+ **Platforms (first release candidate):** Linux x64 and macOS ARM64 are covered
34
+ by CI on Node 22/24. **Windows is not certified** yet (explicit release
35
+ exception); do not assume Windows package/bin/PATH works until a dedicated
36
+ runner validates it.
37
+
38
+ ## Framework support
39
+
40
+ | Target | Current integration |
41
+ | --- | --- |
42
+ | Vite React / Vite Vue | Generated local proxies; use `init` → `install` → `generate` |
43
+ | Tailwind CSS 3 | Content glob added to a supported static config |
44
+ | Tailwind CSS 4 or no Tailwind | Core icon dimensions work without a Tailwind config rewrite |
45
+ | Next App Router / Nuxt | Manual integration; automatic root wrapping and production image behavior are not certified |
46
+ | Vanilla DOM | SVG factories and project runtime; bitmap style groups are unavailable |
47
+ | Windows | Not certified; use a tested macOS or Linux environment for release builds |
48
+
49
+ ## First run
50
+
51
+ On the first interactive start inside a project, the CLI offers to install the
52
+ Free icon library plus its metadata (manual + catalog + manifest). The
53
+ completion marker lives in the cache directory and is only written after a
54
+ successful install; failures print a retry command and never suppress retry.
55
+
56
+ For a React or Vue project, the explicit path is:
57
+
58
+ ```sh
59
+ npm install -D @moewolf/moe-icons-cli
60
+ npx moeicons init
61
+ npx moeicons install free
62
+ npx moeicons generate
63
+ ```
64
+
65
+ Edit `moeicons.config.jsonc` before generating to select the icon IDs and
66
+ themes you need. When changing React/Vue/Vanilla/assets target, edit the config
67
+ first, then run `moeicons install free --target <target>` and `moeicons generate`.
68
+ For React/Vue, installation now keeps only the configured SVG component modules
69
+ and their required files in `.moeicons/artifact`. After adding icon IDs to the
70
+ config, run `moeicons install` again before `moeicons generate`; generate reports
71
+ the missing installed component if this step is skipped. New releases support
72
+ selected-resource downloads as well as full archives; `downloadMode` controls
73
+ this choice. Legacy releases without resource indexes still use a full archive
74
+ in `auto` mode. See Download modes and resource selection below.
75
+ Each theme may select a subset of the registered project icons:
76
+
77
+ ```json
78
+ {
79
+ "icons": ["ui-search", "arrow-bold-right"],
80
+ "defaultTheme": "outline",
81
+ "missingIconPolicy": "fallback",
82
+ "themes": {
83
+ "outline": { "styleGroup": "moe-outline", "icons": ["ui-search"] },
84
+ "solid": { "styleGroup": "moe-solid", "icons": ["arrow-bold-right"] }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Omitting a theme's `icons` selects all registered icons; `[]` selects none.
90
+ Fallback searches the requested theme, then the default theme, then configured
91
+ themes in ASCII name order. An unregistered icon or an icon unavailable in every
92
+ selected theme fails validation/generation. `error` requires each theme to have
93
+ every registered icon. Adding a selected variant requires installing again.
94
+
95
+ Transactions preserve a recovery journal with original bytes in
96
+ `.moeicons/.reconcile-backup-<id>/files`. After a process interruption, run `moeicons recover` at the project root
97
+ before rerunning the original command. It reclaims a lock only when its owner
98
+ process is dead, restores the previous state, and requires a fresh plan. If a file changed after the interruption, recovery stops and retains both
99
+ the user's file and the backups; compare them before restoring manually. A
100
+ completed transaction is identified by its commit marker and is never rolled
101
+ back because backup cleanup failed. Do not delete these directories to bypass
102
+ a recovery conflict. Recovery covers process termination, not power loss or a
103
+ hostile filesystem replacing directories during filesystem operations.
104
+
105
+ The installer rejects a target that disagrees with the config. Import generated
106
+ PascalCase components from your configured `outputDir` (default `src/moeicons`):
107
+
108
+ ```tsx
109
+ import { ArrowBoldRight, MoeiconsProvider } from './moeicons';
110
+
111
+ export function App() {
112
+ return <MoeiconsProvider><ArrowBoldRight size={37} aria-label="Next" /></MoeiconsProvider>;
113
+ }
114
+ ```
115
+
116
+ Vue uses the same generated names and output directory. The CLI imports the
117
+ version-pinned code in `.moeicons/artifact`; `generate` requires a completed
118
+ `install`. After generation, install any dependencies reported by the CLI with
119
+ your package manager before type checking or building. Commit the config,
120
+ generated source, `.moeicons/artifact` and install metadata together if CI must
121
+ build without downloading the library. Treat all of them as one versioned set.
122
+
123
+ ## Metadata
124
+
125
+ Installing or updating a tier writes `<project>/.moeicons/`:
126
+
127
+ ```text
128
+ .moeicons/
129
+ ├── MANUAL.md # human/agent manual (generated by the builder)
130
+ ├── catalog.json # machine-queryable icon catalog (JSON only)
131
+ ├── manifest.json # schema, tier, versions, dependencies, digests
132
+ ├── install-metadata.json
133
+ └── artifact/<target>/
134
+ ```
135
+
136
+ `moeicons update metadata` re-downloads only the small metadata archive for the
137
+ installed code version and reconciles the three files, without touching the
138
+ code artifact.
139
+
140
+ ## Pro resources
141
+
142
+ After an authenticated login (TTY) the CLI offers a one-time pre-download of the
143
+ full Pro code + metadata. If skipped, the authenticated home screen shows the
144
+ resource state and offers `Download / Update / Repair Pro resources`. Logging
145
+ out keeps cached archives but blocks new unauthenticated installs/updates.
146
+
23
147
  ## Security
24
148
 
25
149
  - Tokens are stored in the OS keychain where available.
26
- - API keys are never logged or written in plaintext.
150
+ - API keys are never logged, written in plaintext, passed to subprocesses, or
151
+ embedded in `.moeicons/manifest.json`.
27
152
  - Installation is transactional: failures never corrupt an existing project.
28
- - Downloads verify SHA-256 and never shell out to `unzip`/`tar`.
153
+ - Downloads verify SHA-256 and size, never shell out to `unzip`/`tar`, enforce
154
+ host allowlists and bounded redirects/timeouts, and never forward API
155
+ credentials to signed asset hosts.
156
+ - Code and metadata archives must pass a manifest compatibility check
157
+ (tier/version/digests); mismatches fail loudly instead of mixing tiers.
158
+
159
+ ## Environment
160
+
161
+ | variable | purpose |
162
+ | --- | --- |
163
+ | `MOEICONS_CACHE_DIR` | global artifact/metadata cache (default `~/.moeicons/cache`) |
164
+ | `MOEICONS_FREE_RELEASE_DIR` | test-only free release fixture directory |
165
+ | `MOEICONS_BOOTSTRAP_FILE` | override bootstrap completion marker path |
166
+ | `MOEICONS_PRO_DESCRIPTOR_URL` | test-only Pro descriptor endpoint override (https or loopback http) |
167
+ | `MOEICONS_LIBRARY_VERSIONS_URL` | test-only library versions endpoint override (https or loopback http) |
168
+ | `MOEICONS_TOKEN_STORE_DIR` | 0600 file token store directory |
169
+ | `MOEICONS_DISABLE_SYSTEM_KEYCHAIN` | `1` disables OS keychain storage |
170
+ | `MOEICONS_AUTH0_ISSUER` / `MOEICONS_AUTH0_CLIENT_ID` | Auth0 refresh/revoke |
29
171
 
30
172
  ## Status
31
173
 
32
- Scaffold in progress (Phase 5 of the Moeicons development plan).
174
+ Metadata distribution (`MANUAL-DIST`) implemented: builder-generated manual,
175
+ catalog and manifest, per-tier archives and descriptors, Free bootstrap,
176
+ metadata-only sync, and authenticated Pro pre-download/update.
177
+
178
+ ### Download modes and resource selection
179
+
180
+ Set `downloadMode` in `moeicons.config.json` (schemaVersion 3):
181
+
182
+ ```json
183
+ {
184
+ "schemaVersion": 3,
185
+ "tier": "free",
186
+ "target": "react",
187
+ "outputDir": "src/icons",
188
+ "downloadMode": "auto",
189
+ "icons": ["ui-search", "arrow-bold-right"],
190
+ "defaultTheme": "outline",
191
+ "themes": {
192
+ "outline": { "styleGroup": "moe-outline", "icons": ["ui-search"] },
193
+ "solid": { "styleGroup": "moe-solid", "icons": ["arrow-bold-right"] }
194
+ },
195
+ "missingIconPolicy": "fallback"
196
+ }
197
+ ```
198
+
199
+ - `auto` (default): selected resources when the fixed release advertises them. A legacy release without this capability uses the full archive and explains why.
200
+ - `icons`: require selected resources. Unsupported releases, authorization failures, bad ranges and invalid digests stop the operation; they never trigger a full archive download.
201
+ - `full`: download and verify the complete archive, then install the configured target/resources.
202
+
203
+ `icons` registers the allowed icon IDs. Install/update requires at least one global icon ID; an empty global list stops before version queries or downloads and preserves the existing installation. An individual theme may still use `icons: []`. A theme's `icons` restricts that theme to a subset; omitting it selects every registered ID available in the style group, and `[]` selects none. Fallback chooses the requested theme, then the default theme, then theme names in ASCII order. An icon with no selected variant fails validation. Bitmap themes also select `format` and `imageSize`; changing these fields may require new resources.
204
+
205
+ The CLI reads the release metadata before planning resources, so validation uses that release's catalog. It shows the target, registered icon/theme counts, required file count and compressed payload budget before reading the data object. Verified cache entries reduce traffic. `--json` reports `downloadMode`, `downloadNotes`, `selectedFiles` and `networkBytes` (resource payload only; descriptor/metadata/index traffic is separate).
206
+
207
+ Run `moeicons install free` or `moeicons install pro` after changing the selected icons, themes, bitmap format or size, then `moeicons generate`. `moeicons update` also reconciles the configuration when the release version is unchanged. Missing resources name the affected path and ask for reinstall; stale resources are removed only if still owned and unmodified. A configuration change during download stops before project writes; retry with the final configuration.
208
+
209
+ Generation works offline from the project's verified installed resources; `moeicons recover` also needs no network. Install/update still consult the fixed descriptor and metadata, and Pro checks current entitlement. A resource cache hit does not bypass authorization, and there is no offline install flag. Corrupt cache entries are rejected and fetched again online. Interrupted downloads retain only verified cache members; project files change in one recoverable transaction after all required resources have verified.
210
+
211
+ Version queries have a deadline covering both response headers and body, with bounded JSON sizes. Invalid or unavailable version responses make update fail explicitly. Cache cleanup preserves staging files owned by a live process; disk-full and permission failures report actionable errors.
212
+
213
+ ## MCP project integration
214
+
215
+ Run `moeicons mcp` in the project root containing `package.json`. Use a fixed,
216
+ installed CLI executable in your MCP client's command configuration:
217
+
218
+ ```json
219
+ {
220
+ "command": "node",
221
+ "args": ["/absolute/path/to/node_modules/@moewolf/moe-icons-cli/bin/moeicons.js", "mcp"],
222
+ "cwd": "/absolute/path/to/your/project"
223
+ }
224
+ ```
225
+
226
+ The client must support a working directory for subprocesses. The server uses
227
+ stdio and needs no port. Log in with `moeicons login` outside the MCP server;
228
+ never place credentials in client configuration or browser source code.
229
+
230
+ Use `init_config` (explicit `target: "vue"` for mixed-framework projects), then
231
+ `get_project_config` to obtain the current config hash. `list_icon_groups` and
232
+ `find_icons` expose installed, verified catalog data when available; bundled
233
+ results are identified separately. Call `update_config` with `expectedHash` and
234
+ an explicit patch, `install` with the configured tier, then `generate`.
235
+ `update` updates icon resources, not the CLI executable. Changes to selection
236
+ require resource reconciliation before generation. Read `get_usage_guide` and
237
+ the installed manual before editing application code.
238
+
239
+ For example, add or remove selections without replacing the whole config:
240
+
241
+ ```json
242
+ {
243
+ "expectedHash": "<hash from get_project_config>",
244
+ "patch": {
245
+ "icons": { "add": ["dashboard"], "remove": ["weather-sun"] }
246
+ },
247
+ "dryRun": true
248
+ }
249
+ ```
250
+
251
+ Review the dry-run diff, then call with `dryRun: false`. If the config changed,
252
+ read it again and revise the patch. Generated files are owned by the generator:
253
+ import their named exports rather than editing them. Install the runtime
254
+ `clsx` and a `tailwind-merge` version compatible with your Tailwind version.
255
+ For Vue, put `MoeiconsProvider` around all consuming components and bind its
256
+ `theme` to an exact configured theme key. Give decorative icons `aria-hidden`
257
+ and bitmap icons empty `alt`; icon-only controls require accessible labels.
258
+ Custom application bootstrap can require manual Provider placement; doctor
259
+ reports unsupported shapes rather than certifying them automatically.
260
+
261
+ Available tools: `list_icon_groups`, `get_account`, `get_project_config`,
262
+ `find_icons`, `get_usage_guide`, `init_config`, `update_config`, `install`,
263
+ `update`, and `generate`. The legacy `install_icon_group` tool remains an
264
+ explicit unsupported operation with migration guidance. Tool failures use
265
+ `isError`; malformed protocol arguments use JSON-RPC errors. The server binds
266
+ all operations to one project root and does not expose arbitrary shell commands.
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+
3
+ var MIN_MAJOR = 22;
4
+ var LTS_URL = "https://nodejs.org/en/download";
5
+ var EOL_URL = "https://nodejs.org/en/about/eol";
6
+
7
+ function parseMajor(version) {
8
+ var raw = String(version || "").replace(/^v/, "");
9
+ var major = parseInt(raw.split(".")[0], 10);
10
+ if (!isFinite(major)) return null;
11
+ return major;
12
+ }
13
+
14
+ function assertSupportedNode(version) {
15
+ var major = parseMajor(version);
16
+ var display = String(version || "").charAt(0) === "v" ? String(version) : "v" + String(version);
17
+ if (major === null || major < MIN_MAJOR) {
18
+ return {
19
+ ok: false,
20
+ major: major,
21
+ message:
22
+ "moeicons requires Node.js " + MIN_MAJOR + " or later (you have " + display + ").\n" +
23
+ "Node " + (major === null ? display : major) + " is outside the supported line. Node 20 reached end-of-life on 2026-03-24 and no longer receives security patches.\n" +
24
+ "Install the current Node.js LTS: " + LTS_URL + "\n" +
25
+ "Policy: " + EOL_URL + "\n",
26
+ };
27
+ }
28
+ return { ok: true, major: major, message: "" };
29
+ }
30
+
31
+ module.exports = {
32
+ MIN_MAJOR: MIN_MAJOR,
33
+ LTS_URL: LTS_URL,
34
+ parseMajor: parseMajor,
35
+ assertSupportedNode: assertSupportedNode,
36
+ };
package/bin/moeicons.js CHANGED
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { main } from "../dist/cli.js";
2
+ import { createRequire } from "node:module";
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const { assertSupportedNode } = require("./check-node.cjs");
6
+ const checked = assertSupportedNode(process.version);
7
+ if (!checked.ok) {
8
+ process.stderr.write(checked.message);
9
+ process.exit(1);
10
+ }
11
+
12
+ const { main } = await import("../dist/cli.js");
3
13
 
4
14
  const runtime = {
5
15
  cwd: () => process.cwd(),
@@ -7,6 +17,7 @@ const runtime = {
7
17
  stderr: (text) => process.stderr.write(text),
8
18
  env: process.env,
9
19
  isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
20
+ columns: () => process.stdout.columns,
10
21
  };
11
22
 
12
23
  main(process.argv.slice(2), runtime).then(
@@ -16,15 +16,26 @@ export interface RequestJsonOptions {
16
16
  readonly signal?: AbortSignal;
17
17
  readonly retries?: number;
18
18
  readonly requestId?: string;
19
+ /** Optional stage label included in transport diagnostics (e.g. login create). */
20
+ readonly stage?: string;
21
+ /**
22
+ * Opt-in debug only. Default user-facing errors never include response body
23
+ * text (kind + byte length only). When true, JSON bodies may include a
24
+ * recursively redacted preview; HTML/opaque bodies are still omitted.
25
+ */
26
+ readonly debugBodyPreview?: boolean;
19
27
  }
20
28
  export interface ApiResult<T> {
21
29
  readonly status: number;
22
30
  readonly data: T;
23
31
  readonly requestId: string;
24
32
  }
33
+ /** Recursively redact secret keys and secret-shaped leaf values for debug previews only. */
34
+ export declare function redactJsonForPreview(value: unknown): unknown;
25
35
  /**
26
- * Perform a JSON request against the fixed base URL. Retries GET/idempotent
27
- * calls with capped exponential backoff on 5xx and network errors.
36
+ * Perform a JSON request against the fixed base URL. Retries GET/DELETE
37
+ * (idempotent) calls with capped exponential backoff on 5xx and network errors.
38
+ * POST/PUT never retry. External AbortSignal stops immediately without retry.
28
39
  */
29
40
  export declare function requestJson<T>(config: ApiConfig, path: string, options?: RequestJsonOptions): Promise<ApiResult<T>>;
30
41
  export interface ApiKeyEntitlement {
@@ -1,14 +1,91 @@
1
1
  import { CliError } from "../errors/index.js";
2
+ const SECRET_KEY = /^(access[_-]?token|refresh[_-]?token|polling[_-]?token|exchange[_-]?code|code[_-]?verifier|authorization|password|secret|cookie|set-cookie|email)$/i;
3
+ const JWT_LIKE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
4
+ const BEARER_LIKE = /^Bearer\s+\S+/i;
5
+ const EMAIL_LIKE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+ function isIdempotentMethod(method) {
7
+ return method === "GET" || method === "DELETE";
8
+ }
9
+ function bodyKind(text, contentType) {
10
+ const trimmed = text.trim();
11
+ if (trimmed.length === 0)
12
+ return "empty";
13
+ const lower = (contentType ?? "").toLowerCase();
14
+ if (lower.includes("text/html") || /^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed)) {
15
+ return "html";
16
+ }
17
+ if (lower.includes("application/json") || lower.includes("+json"))
18
+ return "json-declared";
19
+ if (trimmed.startsWith("{") || trimmed.startsWith("["))
20
+ return "json-looking";
21
+ if (lower.includes("text/plain"))
22
+ return "text/plain";
23
+ return "opaque";
24
+ }
25
+ /** Recursively redact secret keys and secret-shaped leaf values for debug previews only. */
26
+ export function redactJsonForPreview(value) {
27
+ if (Array.isArray(value))
28
+ return value.map(redactJsonForPreview);
29
+ if (value && typeof value === "object") {
30
+ const out = {};
31
+ for (const [key, nested] of Object.entries(value)) {
32
+ out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactJsonForPreview(nested);
33
+ }
34
+ return out;
35
+ }
36
+ if (typeof value === "string") {
37
+ if (JWT_LIKE.test(value) || BEARER_LIKE.test(value) || EMAIL_LIKE.test(value))
38
+ return "[redacted]";
39
+ if (/[?&](access_token|refresh_token|code|token)=/i.test(value))
40
+ return "[redacted-url]";
41
+ return value;
42
+ }
43
+ return value;
44
+ }
45
+ function summarizeBody(text, contentType, debugBodyPreview) {
46
+ const kind = bodyKind(text, contentType);
47
+ const base = `body=${kind}, ${text.length} bytes`;
48
+ if (!debugBodyPreview)
49
+ return base;
50
+ if (kind === "html" || kind === "opaque" || kind === "empty" || kind === "text/plain") {
51
+ return base;
52
+ }
53
+ try {
54
+ const parsed = JSON.parse(text);
55
+ const redacted = redactJsonForPreview(parsed);
56
+ const preview = JSON.stringify(redacted).replace(/\s+/g, " ").slice(0, 120);
57
+ return `${base}, preview=${JSON.stringify(preview)}`;
58
+ }
59
+ catch {
60
+ return base;
61
+ }
62
+ }
63
+ function transportError(stage, status, contentType, text, reason, debugBodyPreview) {
64
+ const stagePrefix = stage ? `${stage}: ` : "";
65
+ const ct = contentType ?? "unknown";
66
+ const message = `${stagePrefix}${reason} (HTTP ${status}, content-type=${ct}, ${summarizeBody(text, contentType, debugBodyPreview)})`;
67
+ if (status === 401)
68
+ return new CliError("AUTH_ERROR", message);
69
+ if (status === 403)
70
+ return new CliError("FORBIDDEN", message);
71
+ if (status === 404)
72
+ return new CliError("NOT_FOUND", message);
73
+ if (status === 0)
74
+ return new CliError("NETWORK_ERROR", message);
75
+ return new CliError("VALIDATION_ERROR", message);
76
+ }
2
77
  /**
3
- * Perform a JSON request against the fixed base URL. Retries GET/idempotent
4
- * calls with capped exponential backoff on 5xx and network errors.
78
+ * Perform a JSON request against the fixed base URL. Retries GET/DELETE
79
+ * (idempotent) calls with capped exponential backoff on 5xx and network errors.
80
+ * POST/PUT never retry. External AbortSignal stops immediately without retry.
5
81
  */
6
82
  export async function requestJson(config, path, options = {}) {
7
83
  const method = options.method ?? "GET";
8
84
  const retries = options.retries ?? 0;
9
85
  const timeoutMs = config.timeoutMs ?? 10_000;
10
- const requestId = options.requestId ??
11
- `req-${Math.random().toString(36).slice(2, 10)}`;
86
+ const requestId = options.requestId ?? `req-${Math.random().toString(36).slice(2, 10)}`;
87
+ const debugBodyPreview = options.debugBodyPreview === true;
88
+ const canRetry = isIdempotentMethod(method) && retries > 0;
12
89
  const attempt = async () => {
13
90
  const controller = new AbortController();
14
91
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -23,13 +100,26 @@ export async function requestJson(config, path, options = {}) {
23
100
  headers[options.authHeader ?? "authorization"] = options.auth;
24
101
  }
25
102
  try {
26
- const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}${path}`, {
27
- method,
28
- headers,
29
- ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
30
- signal: controller.signal,
31
- });
103
+ let response;
104
+ try {
105
+ response = await fetch(`${config.baseUrl.replace(/\/$/, "")}${path}`, {
106
+ method,
107
+ headers,
108
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
109
+ signal: controller.signal,
110
+ });
111
+ }
112
+ catch (error) {
113
+ if (error instanceof Error && error.name === "AbortError") {
114
+ if (externalSignal?.aborted) {
115
+ throw new CliError("NETWORK_ERROR", `${options.stage ? `${options.stage}: ` : ""}request was aborted`);
116
+ }
117
+ throw new RetryableTransportError(`${options.stage ? `${options.stage}: ` : ""}request timed out`);
118
+ }
119
+ throw new RetryableTransportError(`${options.stage ? `${options.stage}: ` : ""}network error (${error instanceof Error ? error.message : String(error)})`);
120
+ }
32
121
  const text = await response.text();
122
+ const contentType = response.headers.get("content-type");
33
123
  if (text.length > (config.maxBodyBytes ?? 5 * 1024 * 1024)) {
34
124
  throw new CliError("VALIDATION_ERROR", "response body exceeds size limit");
35
125
  }
@@ -39,21 +129,35 @@ export async function requestJson(config, path, options = {}) {
39
129
  data = JSON.parse(text);
40
130
  }
41
131
  catch {
42
- throw new CliError("VALIDATION_ERROR", "response is not valid JSON");
132
+ throw transportError(options.stage, response.status, contentType, text, "response is not valid JSON", debugBodyPreview);
43
133
  }
44
134
  }
45
- if (response.status === 401)
46
- throw new CliError("AUTH_ERROR", "unauthorized");
47
- if (response.status === 403)
48
- throw new CliError("FORBIDDEN", "forbidden");
135
+ else if (response.ok) {
136
+ data = null;
137
+ }
138
+ else {
139
+ throw transportError(options.stage, response.status, contentType, text, "empty error response", debugBodyPreview);
140
+ }
141
+ if (response.status === 401) {
142
+ throw transportError(options.stage, response.status, contentType, text, "unauthorized", debugBodyPreview);
143
+ }
144
+ if (response.status === 403) {
145
+ throw transportError(options.stage, response.status, contentType, text, "forbidden", debugBodyPreview);
146
+ }
49
147
  if (response.status === 429) {
50
- throw new CliError("NETWORK_ERROR", "rate limited");
148
+ if (canRetry) {
149
+ throw new RetryableTransportError(`${options.stage ? `${options.stage}: ` : ""}rate limited`);
150
+ }
151
+ throw new CliError("NETWORK_ERROR", `${options.stage ? `${options.stage}: ` : ""}rate limited`);
51
152
  }
52
- if (response.status >= 500 && retries > 0) {
53
- throw new RetryableStatus(response.status);
153
+ if (response.status >= 500) {
154
+ if (canRetry) {
155
+ throw new RetryableStatus(response.status);
156
+ }
157
+ throw transportError(options.stage, response.status, contentType, text, `request failed with ${response.status}`, debugBodyPreview);
54
158
  }
55
159
  if (response.status >= 400) {
56
- throw new CliError("NETWORK_ERROR", `request failed with ${response.status}`);
160
+ throw transportError(options.stage, response.status, contentType, text, `request failed with ${response.status}`, debugBodyPreview);
57
161
  }
58
162
  return { status: response.status, data: data, requestId };
59
163
  }
@@ -68,13 +172,27 @@ export async function requestJson(config, path, options = {}) {
68
172
  return await attempt();
69
173
  }
70
174
  catch (error) {
71
- if (error instanceof CliError)
175
+ if (error instanceof CliError) {
176
+ // Auth / schema / validation / not-found never retry.
72
177
  throw error;
73
- if (error instanceof RetryableStatus && i < retries) {
178
+ }
179
+ if (options.signal?.aborted) {
180
+ throw new CliError("NETWORK_ERROR", `${options.stage ? `${options.stage}: ` : ""}request was aborted`);
181
+ }
182
+ const retryable = canRetry &&
183
+ i < retries &&
184
+ (error instanceof RetryableStatus || error instanceof RetryableTransportError);
185
+ if (retryable) {
74
186
  const backoff = Math.min(500 * 2 ** i, 4000);
75
187
  await new Promise((resolve) => setTimeout(resolve, backoff));
76
188
  continue;
77
189
  }
190
+ if (error instanceof RetryableTransportError) {
191
+ throw new CliError("NETWORK_ERROR", error.message);
192
+ }
193
+ if (error instanceof RetryableStatus) {
194
+ throw new CliError("NETWORK_ERROR", `${options.stage ? `${options.stage}: ` : ""}request failed with ${error.status}`);
195
+ }
78
196
  lastError = error;
79
197
  }
80
198
  }
@@ -87,6 +205,12 @@ class RetryableStatus extends Error {
87
205
  this.status = status;
88
206
  }
89
207
  }
208
+ class RetryableTransportError extends Error {
209
+ constructor(message) {
210
+ super(message);
211
+ this.name = "RetryableTransportError";
212
+ }
213
+ }
90
214
  /**
91
215
  * Verify an API key. Trim once, never log, send via the configured header,
92
216
  * map 401/403/429/5xx distinctly. No persistent storage by default.
@@ -1,17 +1,20 @@
1
- import type { StoredSession, TokenStore } from './token-store.js';
1
+ import type { StoredSession, TokenStore } from "./token-store.js";
2
2
  export interface DeviceLoginConfig {
3
3
  readonly apiBaseUrl: string;
4
4
  readonly websiteOrigin: string;
5
5
  readonly auth0Issuer: string;
6
6
  readonly auth0ClientId: string;
7
7
  }
8
+ export type LoginRequestStage = "create" | "poll" | "exchange" | "cleanup";
9
+ export interface DeviceLoginRequestOptions {
10
+ readonly method: "GET" | "POST" | "DELETE";
11
+ readonly auth?: string;
12
+ readonly body?: unknown;
13
+ readonly signal?: AbortSignal;
14
+ readonly stage?: LoginRequestStage;
15
+ }
8
16
  export interface DeviceLoginDependencies {
9
- readonly request: <T>(path: string, options: {
10
- method: 'GET' | 'POST' | 'DELETE';
11
- auth?: string;
12
- body?: unknown;
13
- signal?: AbortSignal;
14
- }) => Promise<{
17
+ readonly request: <T>(path: string, options: DeviceLoginRequestOptions) => Promise<{
15
18
  status: number;
16
19
  data: T;
17
20
  }>;