@moewolf/moe-icons-cli 0.0.1

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 (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +32 -0
  3. package/bin/moeicons.js +20 -0
  4. package/dist/api/client.d.ts +42 -0
  5. package/dist/api/client.js +105 -0
  6. package/dist/auth/callback.d.ts +37 -0
  7. package/dist/auth/callback.js +72 -0
  8. package/dist/auth/device-login.d.ts +29 -0
  9. package/dist/auth/device-login.js +74 -0
  10. package/dist/auth/login.d.ts +41 -0
  11. package/dist/auth/login.js +85 -0
  12. package/dist/auth/open-browser.d.ts +2 -0
  13. package/dist/auth/open-browser.js +15 -0
  14. package/dist/auth/pkce.d.ts +17 -0
  15. package/dist/auth/pkce.js +20 -0
  16. package/dist/auth/token-store.d.ts +38 -0
  17. package/dist/auth/token-store.js +126 -0
  18. package/dist/catalog/catalog.d.ts +32 -0
  19. package/dist/catalog/catalog.js +80 -0
  20. package/dist/catalog/catalog.json +9371 -0
  21. package/dist/cli.d.ts +23 -0
  22. package/dist/cli.js +507 -0
  23. package/dist/commands/parser.d.ts +41 -0
  24. package/dist/commands/parser.js +146 -0
  25. package/dist/core/auth.d.ts +32 -0
  26. package/dist/core/auth.js +131 -0
  27. package/dist/core/bitmap-assets.d.ts +29 -0
  28. package/dist/core/bitmap-assets.js +77 -0
  29. package/dist/core/cli-update.d.ts +43 -0
  30. package/dist/core/cli-update.js +71 -0
  31. package/dist/core/context.d.ts +22 -0
  32. package/dist/core/context.js +1 -0
  33. package/dist/core/free-download.d.ts +66 -0
  34. package/dist/core/free-download.js +193 -0
  35. package/dist/core/generate.d.ts +33 -0
  36. package/dist/core/generate.js +285 -0
  37. package/dist/core/icon-names.d.ts +14 -0
  38. package/dist/core/icon-names.js +54 -0
  39. package/dist/core/init.d.ts +12 -0
  40. package/dist/core/init.js +23 -0
  41. package/dist/core/install.d.ts +67 -0
  42. package/dist/core/install.js +117 -0
  43. package/dist/core/library-update.d.ts +24 -0
  44. package/dist/core/library-update.js +161 -0
  45. package/dist/core/manage.d.ts +24 -0
  46. package/dist/core/manage.js +38 -0
  47. package/dist/core/pro-download.d.ts +29 -0
  48. package/dist/core/pro-download.js +112 -0
  49. package/dist/core/pro-install.d.ts +24 -0
  50. package/dist/core/pro-install.js +72 -0
  51. package/dist/core/release-descriptor.d.ts +40 -0
  52. package/dist/core/release-descriptor.js +114 -0
  53. package/dist/core/resource-variant.d.ts +35 -0
  54. package/dist/core/resource-variant.js +70 -0
  55. package/dist/core/signed-artifact.d.ts +29 -0
  56. package/dist/core/signed-artifact.js +81 -0
  57. package/dist/core/target-subtree.d.ts +49 -0
  58. package/dist/core/target-subtree.js +132 -0
  59. package/dist/core/update-policy.d.ts +31 -0
  60. package/dist/core/update-policy.js +27 -0
  61. package/dist/core/version-service.d.ts +26 -0
  62. package/dist/core/version-service.js +71 -0
  63. package/dist/core/wizard.d.ts +49 -0
  64. package/dist/core/wizard.js +71 -0
  65. package/dist/errors/errors.d.ts +34 -0
  66. package/dist/errors/errors.js +39 -0
  67. package/dist/errors/index.d.ts +1 -0
  68. package/dist/errors/index.js +1 -0
  69. package/dist/generator/cn.d.ts +5 -0
  70. package/dist/generator/cn.js +14 -0
  71. package/dist/generator/generate.d.ts +34 -0
  72. package/dist/generator/generate.js +562 -0
  73. package/dist/generator/theme-resolve.d.ts +26 -0
  74. package/dist/generator/theme-resolve.js +100 -0
  75. package/dist/mcp/server.d.ts +58 -0
  76. package/dist/mcp/server.js +176 -0
  77. package/dist/project/config.d.ts +74 -0
  78. package/dist/project/config.js +353 -0
  79. package/dist/project/detect.d.ts +26 -0
  80. package/dist/project/detect.js +104 -0
  81. package/dist/project/install-metadata.d.ts +29 -0
  82. package/dist/project/install-metadata.js +108 -0
  83. package/dist/project/install.d.ts +115 -0
  84. package/dist/project/install.js +537 -0
  85. package/dist/project/project-lock.d.ts +11 -0
  86. package/dist/project/project-lock.js +81 -0
  87. package/dist/project/tailwind.d.ts +51 -0
  88. package/dist/project/tailwind.js +188 -0
  89. package/dist/project/tar-gz.d.ts +12 -0
  90. package/dist/project/tar-gz.js +100 -0
  91. package/dist/tui/primitives.d.ts +36 -0
  92. package/dist/tui/primitives.js +88 -0
  93. package/dist/ui/banner.d.ts +11 -0
  94. package/dist/ui/banner.js +105 -0
  95. package/dist/ui/clack.d.ts +8 -0
  96. package/dist/ui/clack.js +53 -0
  97. package/dist/ui/create-ui.d.ts +14 -0
  98. package/dist/ui/create-ui.js +16 -0
  99. package/dist/ui/non-interactive.d.ts +5 -0
  100. package/dist/ui/non-interactive.js +30 -0
  101. package/dist/ui/stream.d.ts +11 -0
  102. package/dist/ui/stream.js +50 -0
  103. package/package.json +70 -0
@@ -0,0 +1,353 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "jsonc-parser";
4
+ import { catalog, findCatalogIcon, findCatalogStyleGroup, } from "../catalog/catalog.js";
5
+ export const SUPPORTED_FILENAMES = [
6
+ "moeicons.config.jsonc",
7
+ "moeicons.config.json",
8
+ "moeicons.config.ts",
9
+ "moeicons.config.js",
10
+ ];
11
+ export function findConfigFile(root) {
12
+ for (const name of SUPPORTED_FILENAMES) {
13
+ if (existsSync(join(root, name)))
14
+ return join(root, name);
15
+ }
16
+ return undefined;
17
+ }
18
+ function parseJsonc(text) {
19
+ const errors = [];
20
+ const value = parse(text, errors, {
21
+ allowTrailingComma: true,
22
+ disallowComments: false,
23
+ });
24
+ return { value, errors };
25
+ }
26
+ function flattenIcons(value) {
27
+ if (Array.isArray(value) && value.every((item) => typeof item === "string"))
28
+ return value;
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
30
+ throw new Error("icons must be an array or prefix-group object");
31
+ }
32
+ const groups = value;
33
+ const result = [];
34
+ for (const prefix of Object.keys(groups).sort()) {
35
+ const group = groups[prefix];
36
+ if (!Array.isArray(group) || !group.every((item) => typeof item === "string")) {
37
+ throw new Error(`icons.${prefix} must be an array of icon IDs`);
38
+ }
39
+ result.push(...group);
40
+ }
41
+ return result;
42
+ }
43
+ const ALLOWED_COMMON_KEYS = new Set([
44
+ "schemaVersion",
45
+ "tier",
46
+ "outputDir",
47
+ "defaultTheme",
48
+ "themes",
49
+ "icons",
50
+ "missingIconPolicy",
51
+ ]);
52
+ const ALLOWED_THEME_KEYS = new Set([
53
+ "styleGroup",
54
+ "styles",
55
+ "format",
56
+ "imageSize",
57
+ "defaultSize",
58
+ "strokeWidth",
59
+ "className",
60
+ ]);
61
+ function asRecord(raw) {
62
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
63
+ throw new Error("config must be an object");
64
+ return raw;
65
+ }
66
+ function requireCommonConfigFields(obj, sourceCatalog) {
67
+ const warnings = [];
68
+ if (obj.tier !== "free" && obj.tier !== "pro")
69
+ throw new Error("tier must be free or pro");
70
+ const tier = obj.tier;
71
+ if (typeof obj.outputDir !== "string" || obj.outputDir.length === 0)
72
+ throw new Error("outputDir is required");
73
+ if (typeof obj.defaultTheme !== "string" || obj.defaultTheme.length === 0)
74
+ throw new Error("defaultTheme is required");
75
+ if (typeof obj.themes !== "object" || obj.themes === null || Array.isArray(obj.themes)) {
76
+ throw new Error("themes must be an object");
77
+ }
78
+ if (obj.missingIconPolicy !== undefined &&
79
+ obj.missingIconPolicy !== "fallback" &&
80
+ obj.missingIconPolicy !== "error") {
81
+ throw new Error(`missingIconPolicy must be "fallback" or "error"`);
82
+ }
83
+ const themes = {};
84
+ for (const [name, value] of Object.entries(obj.themes)) {
85
+ if (typeof value !== "object" || value === null || Array.isArray(value))
86
+ throw new Error(`theme ${name} is invalid`);
87
+ const theme = value;
88
+ // Reject unknown theme fields.
89
+ for (const key of Object.keys(theme)) {
90
+ if (!ALLOWED_THEME_KEYS.has(key))
91
+ throw new Error(`unknown field "${key}" in theme "${name}"`);
92
+ }
93
+ if (typeof theme.styleGroup !== "string")
94
+ throw new Error(`theme ${name}.styleGroup is required`);
95
+ const group = findCatalogStyleGroup(theme.styleGroup, sourceCatalog);
96
+ if (!group)
97
+ throw new Error(`unknown style group "${theme.styleGroup}"`);
98
+ if (!group.tiers.includes(tier)) {
99
+ throw new Error(`style group "${group.id}" is not available in ${tier} tier`);
100
+ }
101
+ // styles[] is deprecated — still accepted for migration but warns.
102
+ if (Array.isArray(theme.styles) && theme.styles.length > 0) {
103
+ warnings.push(`theme "${name}": "styles" is deprecated and has no effect; remove it from your config`);
104
+ }
105
+ const format = typeof theme.format === "string" ? theme.format : undefined;
106
+ if (format !== undefined && format !== "svg" && format !== "webp" && format !== "png") {
107
+ throw new Error(`theme ${name}.format is invalid`);
108
+ }
109
+ const imageSize = typeof theme.imageSize === "number" ? theme.imageSize : undefined;
110
+ if (imageSize !== undefined && ![64, 128, 256, 512].includes(imageSize)) {
111
+ throw new Error(`theme ${name}.imageSize is invalid`);
112
+ }
113
+ if (group.type !== "bitmap" &&
114
+ ((format !== undefined && format !== "svg") || imageSize !== undefined)) {
115
+ throw new Error(`SVG theme ${name} cannot define bitmap options`);
116
+ }
117
+ if (group.type === "bitmap" && format !== undefined && !group.formats.includes(format)) {
118
+ throw new Error(`format ${format} is unavailable for ${group.id}`);
119
+ }
120
+ if (group.type === "bitmap" &&
121
+ imageSize !== undefined &&
122
+ !group.imageSizes.includes(imageSize)) {
123
+ throw new Error(`imageSize ${imageSize} is unavailable for ${group.id}`);
124
+ }
125
+ themes[name] = {
126
+ styleGroup: theme.styleGroup,
127
+ ...(format !== undefined ? { format } : {}),
128
+ ...(imageSize !== undefined ? { imageSize: imageSize } : {}),
129
+ ...(typeof theme.defaultSize === "number" ? { defaultSize: theme.defaultSize } : {}),
130
+ ...(typeof theme.strokeWidth === "number" ? { strokeWidth: theme.strokeWidth } : {}),
131
+ ...(typeof theme.className === "string" ? { className: theme.className } : {}),
132
+ };
133
+ }
134
+ if (!(obj.defaultTheme in themes))
135
+ throw new Error(`defaultTheme "${obj.defaultTheme}" is not defined`);
136
+ const icons = flattenIcons(obj.icons);
137
+ for (const iconId of icons)
138
+ if (!findCatalogIcon(iconId, sourceCatalog))
139
+ throw new Error(`unknown icon "${iconId}"`);
140
+ return {
141
+ warnings,
142
+ tier,
143
+ outputDir: obj.outputDir,
144
+ defaultTheme: obj.defaultTheme,
145
+ themes,
146
+ icons,
147
+ ...(obj.missingIconPolicy !== undefined
148
+ ? { missingIconPolicy: obj.missingIconPolicy }
149
+ : {}),
150
+ };
151
+ }
152
+ function normalizeTarget(value) {
153
+ if (value === "react" || value === "vue" || value === "vanilla" || value === "assets") {
154
+ return value;
155
+ }
156
+ throw new Error("target must be react, vue, vanilla, or assets");
157
+ }
158
+ /** Validate a schema version 2 config: target is required, framework is rejected. */
159
+ function validateV2Config(raw, sourceCatalog) {
160
+ const obj = asRecord(raw);
161
+ if (obj.framework !== undefined)
162
+ throw new Error('framework is only supported in schemaVersion 1; use target');
163
+ for (const key of Object.keys(obj)) {
164
+ if (!ALLOWED_COMMON_KEYS.has(key) && key !== "target") {
165
+ throw new Error(`unknown config field "${key}"`);
166
+ }
167
+ }
168
+ if (obj.target === undefined)
169
+ throw new Error("target is required");
170
+ const target = normalizeTarget(obj.target);
171
+ const common = requireCommonConfigFields(obj, sourceCatalog);
172
+ return {
173
+ config: {
174
+ schemaVersion: 2,
175
+ tier: common.tier,
176
+ target,
177
+ outputDir: common.outputDir,
178
+ defaultTheme: common.defaultTheme,
179
+ themes: common.themes,
180
+ icons: common.icons,
181
+ ...(common.missingIconPolicy !== undefined
182
+ ? { missingIconPolicy: common.missingIconPolicy }
183
+ : {}),
184
+ },
185
+ warnings: common.warnings,
186
+ };
187
+ }
188
+ /** Validate a legacy schema version 1 config: framework is required, target is rejected. */
189
+ function validateV1Config(raw, sourceCatalog) {
190
+ const obj = asRecord(raw);
191
+ for (const key of Object.keys(obj)) {
192
+ if (!ALLOWED_COMMON_KEYS.has(key) && key !== "framework") {
193
+ throw new Error(`unknown config field "${key}"`);
194
+ }
195
+ }
196
+ if (obj.target !== undefined)
197
+ throw new Error("v1 config cannot set target; migrate to schemaVersion 2");
198
+ if (obj.framework !== "react" && obj.framework !== "vue")
199
+ throw new Error("framework must be react or vue");
200
+ const common = requireCommonConfigFields(obj, sourceCatalog);
201
+ return {
202
+ config: {
203
+ schemaVersion: 2,
204
+ tier: common.tier,
205
+ target: obj.framework,
206
+ outputDir: common.outputDir,
207
+ defaultTheme: common.defaultTheme,
208
+ themes: common.themes,
209
+ icons: common.icons,
210
+ ...(common.missingIconPolicy !== undefined
211
+ ? { missingIconPolicy: common.missingIconPolicy }
212
+ : {}),
213
+ },
214
+ warnings: common.warnings,
215
+ };
216
+ }
217
+ export function readMoeiconsConfig(root, sourceCatalog = catalog) {
218
+ const configPath = findConfigFile(root);
219
+ if (!configPath)
220
+ return { kind: "missing" };
221
+ if (!configPath.endsWith(".json") && !configPath.endsWith(".jsonc")) {
222
+ return {
223
+ kind: "invalid",
224
+ message: "TS/JS config requires transpilation; use moeicons.config.jsonc",
225
+ };
226
+ }
227
+ const parsed = parseJsonc(readFileSync(configPath, "utf8"));
228
+ if (parsed.errors.length > 0 || parsed.value === undefined)
229
+ return { kind: "invalid", message: `cannot parse ${configPath}` };
230
+ const version = typeof parsed.value === "object" && parsed.value !== null && !Array.isArray(parsed.value)
231
+ ? parsed.value.schemaVersion
232
+ : undefined;
233
+ if (typeof version !== "number") {
234
+ return { kind: "invalid", message: "schemaVersion must be 1 or 2" };
235
+ }
236
+ if (version !== 1 && version !== 2)
237
+ return { kind: "unsupported", version };
238
+ try {
239
+ if (version === 1) {
240
+ const validated = validateV1Config(parsed.value, sourceCatalog);
241
+ validated.warnings.unshift('config schema v1 migrated "framework" to "target"');
242
+ return { kind: "ok", config: validated.config, warnings: validated.warnings };
243
+ }
244
+ const validated = validateV2Config(parsed.value, sourceCatalog);
245
+ return { kind: "ok", config: validated.config, warnings: validated.warnings };
246
+ }
247
+ catch (error) {
248
+ return { kind: "invalid", message: error instanceof Error ? error.message : String(error) };
249
+ }
250
+ }
251
+ /** Resolve the canonical v2 target from either a normalized v2 or legacy v1 input. */
252
+ function targetFromInput(current) {
253
+ if ("target" in current)
254
+ return current.target;
255
+ return current.framework;
256
+ }
257
+ export function mergeMoeiconsConfig(current, patch) {
258
+ return {
259
+ schemaVersion: 2,
260
+ tier: patch.tier ?? current.tier,
261
+ target: patch.target ?? targetFromInput(current),
262
+ outputDir: patch.outputDir ?? current.outputDir,
263
+ defaultTheme: patch.defaultTheme ?? current.defaultTheme,
264
+ themes: patch.themes ?? current.themes,
265
+ icons: patch.icons ?? current.icons,
266
+ ...(patch.missingIconPolicy !== undefined
267
+ ? { missingIconPolicy: patch.missingIconPolicy }
268
+ : current.missingIconPolicy !== undefined
269
+ ? { missingIconPolicy: current.missingIconPolicy }
270
+ : {}),
271
+ };
272
+ }
273
+ export function createMoeiconsConfig(options) {
274
+ const tier = options.tier ?? "free";
275
+ return {
276
+ schemaVersion: 2,
277
+ tier,
278
+ target: options.target ?? options.framework ?? "react",
279
+ outputDir: options.outputDir ?? "src/moeicons",
280
+ defaultTheme: "outline",
281
+ themes: options.themes ?? {
282
+ outline: { styleGroup: "moe-outline", className: "text-zinc-700" },
283
+ },
284
+ icons: options.icons ?? [],
285
+ missingIconPolicy: "fallback",
286
+ };
287
+ }
288
+ /** Render the editable, grouped JSONC skeleton used by `init`. */
289
+ export function renderMoeiconsConfigJsonc(options) {
290
+ const tier = options.tier ?? "free";
291
+ const target = options.target ?? options.framework ?? "react";
292
+ // Collect style groups available for this tier, ordered by id.
293
+ const availableGroups = catalog.styleGroups
294
+ .filter((g) => g.tiers.includes(tier))
295
+ .sort((a, b) => a.id.localeCompare(b.id));
296
+ const defaultThemeName = "outline";
297
+ // Build theme entries: one per available SVG group (bitmap groups get their
298
+ // own entry with format/imageSize annotations).
299
+ const themeLines = [];
300
+ for (const group of availableGroups) {
301
+ const themeName = group.id.replace(/^moe-/, "");
302
+ if (group.type === "bitmap") {
303
+ // List available format/imageSize combinations as inline comments.
304
+ const formatsComment = `// format options: ${group.formats.join(", ")}`;
305
+ const sizesComment = `// imageSize options: ${group.imageSizes.join(", ")}`;
306
+ themeLines.push(` // ${themeName} — bitmap style (${group.id})`, ` // ${formatsComment}`, ` // ${sizesComment}`, ` // ${JSON.stringify(themeName)}: {`, ` // "styleGroup": ${JSON.stringify(group.id)},`, ` // "format": "webp",`, ` // "imageSize": 256`, ` // },`);
307
+ }
308
+ else {
309
+ themeLines.push(` ${JSON.stringify(themeName)}: {`, ` "styleGroup": ${JSON.stringify(group.id)}`, ` },`);
310
+ }
311
+ }
312
+ // Collect icons: only those available in at least one style group of this tier,
313
+ // grouped by prefix and default-selected (all selected).
314
+ const tierGroupIds = new Set(availableGroups.map((g) => g.id));
315
+ const groups = new Map();
316
+ for (const icon of catalog.icons) {
317
+ if (!icon.availableIn.some((sg) => tierGroupIds.has(sg)))
318
+ continue;
319
+ const ids = groups.get(icon.prefix) ?? [];
320
+ ids.push(icon.id);
321
+ groups.set(icon.prefix, ids);
322
+ }
323
+ const iconLines = [...groups.entries()]
324
+ .sort(([a], [b]) => a.localeCompare(b))
325
+ .flatMap(([prefix, ids]) => [
326
+ ` // ${prefix} icons`,
327
+ ` ${JSON.stringify(prefix)}: [`,
328
+ ...ids.map((id) => ` ${JSON.stringify(id)},`),
329
+ " ],",
330
+ ]);
331
+ return [
332
+ "{",
333
+ ` "schemaVersion": 2,`,
334
+ ` "tier": ${JSON.stringify(tier)},`,
335
+ ` "target": ${JSON.stringify(target)},`,
336
+ ` "outputDir": "src/moeicons",`,
337
+ ` "defaultTheme": ${JSON.stringify(defaultThemeName)},`,
338
+ ' "themes": {',
339
+ ...themeLines,
340
+ ` // Set defaultTheme above to one of: ${availableGroups
341
+ .filter((g) => g.type !== "bitmap")
342
+ .map((g) => JSON.stringify(g.id.replace(/^moe-/, "")))
343
+ .join(", ")}`,
344
+ " },",
345
+ " // Comment out individual IDs or a complete prefix group to exclude it.",
346
+ ' "icons": {',
347
+ ...iconLines,
348
+ " },",
349
+ ' "missingIconPolicy": "fallback"',
350
+ "}",
351
+ "",
352
+ ].join("\n");
353
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Project/workspace/package-manager detection. Detection never writes and never
3
+ * guesses; ambiguous cases are errors that require selection.
4
+ */
5
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "unknown";
6
+ export interface DetectedProject {
7
+ /** Absolute path to the directory containing package.json. */
8
+ readonly root: string;
9
+ readonly packageManager: PackageManager;
10
+ /** Workspace members if this root declares workspaces. */
11
+ readonly workspaceMembers: readonly string[];
12
+ readonly framework: "react" | "vue" | "unknown";
13
+ }
14
+ /** Walk upward from startDir to find the nearest package.json. */
15
+ export declare function findProjectRoot(startDir: string): string | undefined;
16
+ /**
17
+ * Detect the package manager by lockfile precedence: pnpm-lock.yaml, then
18
+ * yarn.lock, then package-lock.json. Conflicting lockfiles are an error.
19
+ */
20
+ export declare function detectPackageManager(root: string): PackageManager;
21
+ /** Read npm/pnpm workspace declarations. */
22
+ export declare function detectWorkspace(root: string): readonly string[];
23
+ /** Detect project at startDir, returning root + workspace members. */
24
+ export declare function detectProject(startDir: string): DetectedProject | undefined;
25
+ /** Confirm a target is inside a selected workspace and not node_modules. */
26
+ export declare function assertWritableProject(root: string, target: string, workspaceMembers: readonly string[]): string[];
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, dirname, parse } from "node:path";
3
+ /** Walk upward from startDir to find the nearest package.json. */
4
+ export function findProjectRoot(startDir) {
5
+ let dir = startDir;
6
+ for (;;) {
7
+ if (existsSync(join(dir, "package.json"))) {
8
+ return dir;
9
+ }
10
+ const parent = dirname(dir);
11
+ if (parent === dir)
12
+ return undefined;
13
+ dir = parent;
14
+ }
15
+ }
16
+ function readJsonSafe(file) {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(file, "utf8"));
19
+ if (typeof raw === "object" && raw !== null)
20
+ return raw;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * Detect the package manager by lockfile precedence: pnpm-lock.yaml, then
29
+ * yarn.lock, then package-lock.json. Conflicting lockfiles are an error.
30
+ */
31
+ export function detectPackageManager(root) {
32
+ const hasPnpm = existsSync(join(root, "pnpm-lock.yaml"));
33
+ const hasYarn = existsSync(join(root, "yarn.lock"));
34
+ const hasNpm = existsSync(join(root, "package-lock.json"));
35
+ const present = [hasPnpm, hasYarn, hasNpm].filter(Boolean).length;
36
+ if (present > 1) {
37
+ // conflict: caller must ask the user to choose, never guess
38
+ return "unknown";
39
+ }
40
+ if (hasPnpm)
41
+ return "pnpm";
42
+ if (hasYarn)
43
+ return "yarn";
44
+ if (hasNpm)
45
+ return "npm";
46
+ return "unknown";
47
+ }
48
+ /** Read npm/pnpm workspace declarations. */
49
+ export function detectWorkspace(root) {
50
+ const packageJson = readJsonSafe(join(root, "package.json"));
51
+ if (packageJson) {
52
+ const workspaces = packageJson.workspaces;
53
+ if (typeof workspaces === "string")
54
+ return [workspaces];
55
+ if (Array.isArray(workspaces)) {
56
+ return workspaces.filter((w) => typeof w === "string");
57
+ }
58
+ }
59
+ if (existsSync(join(root, "pnpm-workspace.yaml"))) {
60
+ const content = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8");
61
+ const lines = content
62
+ .split("\n")
63
+ .map((l) => l.trim())
64
+ .filter((l) => !l.startsWith("#") && l.length > 0);
65
+ const packages = lines
66
+ .filter((l) => !l.startsWith("packages:"))
67
+ .map((l) => l.replace(/^\s*-\s*/, "").replace(/["']/g, "").trim())
68
+ .filter((l) => l.length > 0);
69
+ return packages;
70
+ }
71
+ return [];
72
+ }
73
+ /** Detect project at startDir, returning root + workspace members. */
74
+ export function detectProject(startDir) {
75
+ const root = findProjectRoot(startDir);
76
+ if (!root)
77
+ return undefined;
78
+ const packageManager = detectPackageManager(root);
79
+ const workspaceMembers = detectWorkspace(root);
80
+ const pkg = readJsonSafe(join(root, "package.json"));
81
+ const dependencyNames = new Set([
82
+ ...Object.keys(pkg?.dependencies ?? {}),
83
+ ...Object.keys(pkg?.devDependencies ?? {}),
84
+ ]);
85
+ const hasReact = dependencyNames.has("react");
86
+ const hasVue = dependencyNames.has("vue");
87
+ const framework = hasReact === hasVue ? "unknown" : hasVue ? "vue" : "react";
88
+ return { root, packageManager, workspaceMembers, framework };
89
+ }
90
+ /** Confirm a target is inside a selected workspace and not node_modules. */
91
+ export function assertWritableProject(root, target, workspaceMembers) {
92
+ const errors = [];
93
+ if (target.split(/[\\/]/).includes("node_modules")) {
94
+ errors.push("target must not be inside node_modules");
95
+ }
96
+ if (workspaceMembers.length > 0) {
97
+ const resolvedRoot = parse(root).root;
98
+ const inside = workspaceMembers.some((member) => target.startsWith(join(root, member)) || target === root);
99
+ if (!inside) {
100
+ errors.push(`target is outside the selected workspace at ${resolvedRoot}`);
101
+ }
102
+ }
103
+ return errors;
104
+ }
@@ -0,0 +1,29 @@
1
+ export interface InstallMetadata {
2
+ readonly schemaVersion: 1;
3
+ readonly artifactVersion: string;
4
+ readonly tier: "free" | "pro";
5
+ readonly target: "react" | "vue" | "vanilla" | "assets";
6
+ readonly descriptorSha256: string;
7
+ readonly artifactSha256: string;
8
+ readonly catalogSha256: string;
9
+ readonly installedAt: string;
10
+ readonly managedFiles: Readonly<Record<string, string>>;
11
+ /** Verified target subtree hash/size recorded at install time. */
12
+ readonly targetSha256?: string;
13
+ readonly targetFileCount?: number;
14
+ readonly targetByteCount?: number;
15
+ }
16
+ export type InstalledResourceState = {
17
+ readonly kind: "ok";
18
+ readonly metadata: InstallMetadata;
19
+ } | {
20
+ readonly kind: "missing";
21
+ readonly message: string;
22
+ } | {
23
+ readonly kind: "invalid";
24
+ readonly message: string;
25
+ };
26
+ export declare function sha256Bytes(value: string | Uint8Array): string;
27
+ export declare function parseInstallMetadata(raw: string): InstallMetadata | undefined;
28
+ export declare function serializeInstallMetadata(metadata: InstallMetadata): string;
29
+ export declare function readInstalledResourceState(projectRoot: string, expectedTier?: "free" | "pro"): InstalledResourceState;
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const SHA256 = /^[a-f0-9]{64}$/;
5
+ const VERSION = /^\d+\.\d+\.\d+(?:-(?:alpha|beta))?$/;
6
+ const UTC_RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
7
+ export function sha256Bytes(value) {
8
+ return createHash("sha256").update(value).digest("hex");
9
+ }
10
+ function safeManagedPath(path) {
11
+ return (path.length > 0 &&
12
+ !path.startsWith("/") &&
13
+ !/^[A-Za-z]:/.test(path) &&
14
+ !path.includes("\\") &&
15
+ !path.split("/").includes(".."));
16
+ }
17
+ export function parseInstallMetadata(raw) {
18
+ try {
19
+ const value = JSON.parse(raw);
20
+ const allowed = new Set([
21
+ "schemaVersion",
22
+ "artifactVersion",
23
+ "tier",
24
+ "target",
25
+ "descriptorSha256",
26
+ "artifactSha256",
27
+ "catalogSha256",
28
+ "installedAt",
29
+ "managedFiles",
30
+ "targetSha256",
31
+ "targetFileCount",
32
+ "targetByteCount",
33
+ ]);
34
+ if (Object.keys(value).some((key) => !allowed.has(key)) || value.schemaVersion !== 1)
35
+ return undefined;
36
+ if (typeof value.artifactVersion !== "string" || !VERSION.test(value.artifactVersion))
37
+ return undefined;
38
+ if (value.tier !== "free" && value.tier !== "pro")
39
+ return undefined;
40
+ if (!["react", "vue", "vanilla", "assets"].includes(value.target))
41
+ return undefined;
42
+ if (typeof value.descriptorSha256 !== "string" || !SHA256.test(value.descriptorSha256))
43
+ return undefined;
44
+ if (typeof value.artifactSha256 !== "string" || !SHA256.test(value.artifactSha256))
45
+ return undefined;
46
+ if (typeof value.catalogSha256 !== "string" || !SHA256.test(value.catalogSha256))
47
+ return undefined;
48
+ if (value.targetSha256 !== undefined &&
49
+ (typeof value.targetSha256 !== "string" || !SHA256.test(value.targetSha256)))
50
+ return undefined;
51
+ if (value.targetFileCount !== undefined &&
52
+ (typeof value.targetFileCount !== "number" || !Number.isSafeInteger(value.targetFileCount)))
53
+ return undefined;
54
+ if (value.targetByteCount !== undefined &&
55
+ (typeof value.targetByteCount !== "number" || !Number.isSafeInteger(value.targetByteCount)))
56
+ return undefined;
57
+ if (typeof value.installedAt !== "string" ||
58
+ !UTC_RFC3339.test(value.installedAt) ||
59
+ Number.isNaN(Date.parse(value.installedAt)))
60
+ return undefined;
61
+ if (typeof value.managedFiles !== "object" ||
62
+ value.managedFiles === null ||
63
+ Array.isArray(value.managedFiles))
64
+ return undefined;
65
+ const managedFiles = value.managedFiles;
66
+ if (Object.keys(managedFiles).length === 0 ||
67
+ Object.entries(managedFiles).some(([path, hash]) => !safeManagedPath(path) || typeof hash !== "string" || !SHA256.test(hash)))
68
+ return undefined;
69
+ return value;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ export function serializeInstallMetadata(metadata) {
76
+ return `${JSON.stringify(metadata, null, 2)}\n`;
77
+ }
78
+ export function readInstalledResourceState(projectRoot, expectedTier) {
79
+ const metadataPath = join(projectRoot, ".moeicons", "install-metadata.json");
80
+ if (!existsSync(metadataPath))
81
+ return {
82
+ kind: "missing",
83
+ message: "managed install metadata is missing; run repair or reinstall",
84
+ };
85
+ const metadata = parseInstallMetadata(readFileSync(metadataPath, "utf8"));
86
+ if (!metadata)
87
+ return {
88
+ kind: "invalid",
89
+ message: "managed install metadata is invalid; run repair or reinstall",
90
+ };
91
+ if (expectedTier && metadata.tier !== expectedTier)
92
+ return {
93
+ kind: "invalid",
94
+ message: `installed tier ${metadata.tier} does not match config tier ${expectedTier}`,
95
+ };
96
+ for (const [relative, expectedHash] of Object.entries(metadata.managedFiles)) {
97
+ const path = join(projectRoot, relative);
98
+ if (!existsSync(path))
99
+ return { kind: "invalid", message: `managed file is missing: ${relative}` };
100
+ const actual = sha256Bytes(readFileSync(path));
101
+ if (actual !== expectedHash)
102
+ return { kind: "invalid", message: `managed file was modified: ${relative}` };
103
+ }
104
+ const catalogHash = metadata.managedFiles[".moeicons/catalog.json"];
105
+ if (!catalogHash || catalogHash !== metadata.catalogSha256)
106
+ return { kind: "invalid", message: "catalog hash does not match managed metadata" };
107
+ return { kind: "ok", metadata };
108
+ }