@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
@@ -1,59 +1,100 @@
1
1
  /**
2
- * G0/G1/G2: styleGroupId vs resourceVariantId.
3
- * Variant ids are deterministic: `<styleGroupId>-<webp|png>-<64|128|256|512>`.
4
- * Parse from the right: size, then format, remainder is styleGroupId.
2
+ * MEDIA-FORMAT-V2: styleGroupId vs resourceVariantId.
3
+ *
4
+ * Canonical variant ids are the actual source directory names:
5
+ * `<styleGroupId>-<imageSize>-<format>` (e.g. `example-bitmap-style-256-webp`). Parsing
6
+ * is controlled keyword-token based, never substring based; `3d` is an ordinary
7
+ * token. The legacy `<group ending -3d>-<format>-<size>` form is only readable
8
+ * through the explicit `parseLegacyResourceVariantId` compatibility branch.
5
9
  */
6
- export const BITMAP_STYLE_GROUP_RE = /^moe-[a-z0-9]+(?:-[a-z0-9]+)*-3d$/;
7
- export const BITMAP_FORMATS = ["webp", "png"];
10
+ export const BITMAP_STYLE_GROUP_RE = /^moe-[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ export const LEGACY_BITMAP_STYLE_GROUP_RE = /^moe-[a-z0-9]+(?:-[a-z0-9]+)*-3d$/;
12
+ export const BITMAP_FORMATS = ["png", "webp"];
8
13
  export const BITMAP_SIZES = [64, 128, 256, 512];
9
14
  export const DEFAULT_BITMAP_FORMAT = "webp";
10
15
  export const DEFAULT_BITMAP_SIZE = 256;
11
- export function isBitmapStyleGroupId(id) {
12
- return BITMAP_STYLE_GROUP_RE.test(id);
13
- }
14
16
  export function isBitmapFormat(value) {
15
17
  return BITMAP_FORMATS.includes(value);
16
18
  }
17
19
  export function isBitmapSize(value) {
18
20
  return BITMAP_SIZES.includes(value);
19
21
  }
20
- /** Build a resourceVariantId; never invent aliases. */
22
+ /** Build the canonical v2 resourceVariantId (`<group>-<size>-<format>`). */
21
23
  export function buildResourceVariantId(styleGroupId, format = DEFAULT_BITMAP_FORMAT, imageSize = DEFAULT_BITMAP_SIZE) {
22
- if (!isBitmapStyleGroupId(styleGroupId)) {
23
- throw new Error(`styleGroupId "${styleGroupId}" is not a bitmap style group`);
24
+ if (!BITMAP_STYLE_GROUP_RE.test(styleGroupId)) {
25
+ throw new Error(`styleGroupId "${styleGroupId}" is not a valid style group`);
24
26
  }
25
- return `${styleGroupId}-${format}-${imageSize}`;
27
+ if (!isBitmapFormat(format))
28
+ throw new Error(`unsupported bitmap format "${format}"`);
29
+ if (!isBitmapSize(imageSize))
30
+ throw new Error(`unsupported bitmap imageSize ${String(imageSize)}`);
31
+ return `${styleGroupId}-${imageSize}-${format}`;
26
32
  }
27
33
  /**
28
- * Parse `<styleGroupId>-<format>-<size>` from the right.
29
- * Rejects guessing by scanning for an arbitrary `-3d` substring.
34
+ * Parse a v2 name using complete `-` tokens. Returns undefined when the name is
35
+ * not a bitmap candidate (no format token); throws on duplicate/conflicting
36
+ * keywords; returns undefined when the canonical rebuilt id differs (e.g. the
37
+ * legacy format-before-size order).
30
38
  */
39
+ export function parseBitmapName(name) {
40
+ const tokens = name.split("-");
41
+ const formatIndexes = [];
42
+ const sizeIndexes = [];
43
+ tokens.forEach((token, index) => {
44
+ if (isBitmapFormat(token))
45
+ formatIndexes.push(index);
46
+ if (/^(?:64|128|256|512)$/.test(token))
47
+ sizeIndexes.push(index);
48
+ });
49
+ if (formatIndexes.length === 0)
50
+ return undefined;
51
+ if (formatIndexes.length !== 1 || sizeIndexes.length !== 1) {
52
+ throw new Error(`bitmap name "${name}" must contain exactly one format token and one size token`);
53
+ }
54
+ const format = tokens[formatIndexes[0]];
55
+ const imageSize = Number(tokens[sizeIndexes[0]]);
56
+ const removed = new Set([formatIndexes[0], sizeIndexes[0]]);
57
+ const styleGroupId = tokens.filter((_, index) => !removed.has(index)).join("-");
58
+ if (!BITMAP_STYLE_GROUP_RE.test(styleGroupId)) {
59
+ throw new Error(`bitmap name "${name}" derives an invalid styleGroupId "${styleGroupId}"`);
60
+ }
61
+ const canonical = `${styleGroupId}-${imageSize}-${format}`;
62
+ if (canonical !== name)
63
+ return undefined;
64
+ return { styleGroupId, format, imageSize, resourceVariantId: canonical };
65
+ }
66
+ /** Parse a canonical v2 resourceVariantId or throw. */
31
67
  export function parseResourceVariantId(resourceVariantId) {
32
- const parts = resourceVariantId.split("-");
33
- if (parts.length < 4) {
68
+ const parsed = parseBitmapName(resourceVariantId);
69
+ if (!parsed)
34
70
  throw new Error(`invalid resourceVariantId "${resourceVariantId}"`);
35
- }
36
- const sizeToken = parts[parts.length - 1] ?? "";
37
- const formatToken = parts[parts.length - 2] ?? "";
38
- const imageSize = Number(sizeToken);
39
- if (!isBitmapSize(imageSize)) {
71
+ return parsed;
72
+ }
73
+ /**
74
+ * Explicit v1 compatibility branch: legacy ids are
75
+ * `<styleGroupId ending -3d>-<format>-<size>`. Never guessed by the v2 parser.
76
+ */
77
+ export function parseLegacyResourceVariantId(resourceVariantId) {
78
+ const parts = resourceVariantId.split("-");
79
+ if (parts.length < 4)
80
+ throw new Error(`invalid legacy resourceVariantId "${resourceVariantId}"`);
81
+ const imageSize = Number(parts[parts.length - 1]);
82
+ const format = parts[parts.length - 2] ?? "";
83
+ const styleGroupId = parts.slice(0, -2).join("-");
84
+ if (!isBitmapSize(imageSize))
40
85
  throw new Error(`invalid imageSize in resourceVariantId "${resourceVariantId}"`);
41
- }
42
- if (!isBitmapFormat(formatToken)) {
86
+ if (!isBitmapFormat(format))
43
87
  throw new Error(`invalid format in resourceVariantId "${resourceVariantId}"`);
88
+ if (!LEGACY_BITMAP_STYLE_GROUP_RE.test(styleGroupId)) {
89
+ throw new Error(`invalid legacy styleGroupId derived from "${resourceVariantId}"`);
44
90
  }
45
- const styleGroupId = parts.slice(0, -2).join("-");
46
- if (!isBitmapStyleGroupId(styleGroupId)) {
47
- throw new Error(`invalid styleGroupId derived from "${resourceVariantId}"`);
48
- }
49
- return {
50
- styleGroupId,
51
- format: formatToken,
52
- imageSize,
53
- resourceVariantId: buildResourceVariantId(styleGroupId, formatToken, imageSize),
54
- };
91
+ return { styleGroupId, format, imageSize, resourceVariantId };
55
92
  }
56
- /** Resolve format/size for a bitmap theme, applying catalog defaults when omitted. */
93
+ /**
94
+ * Build a canonical variant from a group id and optional format/size, applying
95
+ * the schema defaults `webp`/`256`. Availability must still be checked against
96
+ * the catalog by the caller (MEDIA-FORMAT-V2 D-05/B6).
97
+ */
57
98
  export function resolveResourceVariant(styleGroupId, options = {}) {
58
99
  const format = options.format === undefined ? DEFAULT_BITMAP_FORMAT : options.format;
59
100
  const imageSize = options.imageSize === undefined ? DEFAULT_BITMAP_SIZE : options.imageSize;
@@ -0,0 +1,59 @@
1
+ import { type CacheIo } from "./cache.js";
2
+ import { type IconCatalog } from "../catalog/catalog.js";
3
+ import type { MoeiconsConfigFile } from "../project/config.js";
4
+ export interface ResourceRef {
5
+ readonly filename: string;
6
+ readonly size: number;
7
+ readonly sha256: string;
8
+ }
9
+ export interface ResourceRefs {
10
+ readonly schemaVersion: 1;
11
+ readonly index: ResourceRef;
12
+ readonly bundle: ResourceRef;
13
+ }
14
+ export interface ResourceFile {
15
+ readonly offset: number;
16
+ readonly compressedSize: number;
17
+ readonly compressedSha256: string;
18
+ readonly size: number;
19
+ readonly sha256: string;
20
+ readonly requires: readonly string[];
21
+ }
22
+ export interface ResourceIndex {
23
+ readonly schemaVersion: 1;
24
+ readonly version: string;
25
+ readonly tier: "free" | "pro";
26
+ readonly artifactSha256: string;
27
+ readonly bundle: ResourceRef;
28
+ readonly files: Readonly<Record<string, ResourceFile>>;
29
+ }
30
+ export declare function parseResourceRefs(value: unknown): ResourceRefs;
31
+ export declare function parseResourceIndex(bytes: Uint8Array, refs: ResourceRefs, expected: {
32
+ version: string;
33
+ tier: "free" | "pro";
34
+ artifactSha256: string;
35
+ }): ResourceIndex;
36
+ export declare const EMPTY_DOWNLOAD_SELECTION_MESSAGE = "config.icons must contain at least one icon ID before install/update; edit icons in moeicons.config.json or run moeicons init";
37
+ export declare function assertDownloadSelection(config: MoeiconsConfigFile): void;
38
+ export declare function planSelectedResources(config: MoeiconsConfigFile, catalog: IconCatalog, index: ResourceIndex): {
39
+ paths: readonly string[];
40
+ payloadBytes: number;
41
+ expandedBytes: number;
42
+ fallbacks: readonly string[];
43
+ };
44
+ export declare function verifyResource(bytes: Uint8Array, entry: ResourceFile, name: string): Uint8Array;
45
+ export declare function downloadSelectedResources(index: ResourceIndex, indexSha256: string, paths: readonly string[], deps: {
46
+ io: CacheIo;
47
+ cacheDir: string;
48
+ fetch: typeof fetch;
49
+ signal: AbortSignal;
50
+ allowedHosts: readonly string[];
51
+ allowLoopback?: boolean;
52
+ offline?: boolean;
53
+ getBundleUrl: () => Promise<string>;
54
+ readRange?: (start: number, size: number) => Promise<Uint8Array>;
55
+ }): Promise<{
56
+ files: Record<string, Uint8Array>;
57
+ networkBytes: number;
58
+ cacheHits: number;
59
+ }>;
@@ -0,0 +1,350 @@
1
+ import { gunzipSync } from "node:zlib";
2
+ import { join } from "node:path";
3
+ import { sha256Bytes } from "../project/install-metadata.js";
4
+ import { CliError } from "../errors/index.js";
5
+ import { cacheArtifact } from "./cache.js";
6
+ import { resolveIconTheme } from "./icon-selection.js";
7
+ import { findCatalogStyleGroup } from "../catalog/catalog.js";
8
+ import { toProxyName } from "./icon-names.js";
9
+ import { resolveResourceVariant } from "./resource-variant.js";
10
+ const SHA = /^[a-f0-9]{64}$/;
11
+ const safe = (path) => /^(react|vue|vanilla|assets)\/[A-Za-z0-9_./-]+$/.test(path) &&
12
+ path.split("/").every((part) => part && part !== "." && part !== "..");
13
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14
+ const fail = (message) => {
15
+ throw new CliError("VALIDATION_ERROR", message);
16
+ };
17
+ export function parseResourceRefs(value) {
18
+ if (!record(value) ||
19
+ value.schemaVersion !== 1 ||
20
+ Object.keys(value).some((k) => !["schemaVersion", "index", "bundle"].includes(k)))
21
+ return fail("invalid selected resource contract");
22
+ for (const kind of ["index", "bundle"]) {
23
+ const ref = value[kind];
24
+ if (!record(ref) ||
25
+ Object.keys(ref).some((k) => !["filename", "size", "sha256"].includes(k)) ||
26
+ typeof ref.filename !== "string" ||
27
+ !/^[A-Za-z0-9._-]+$/.test(ref.filename) ||
28
+ typeof ref.size !== "number" ||
29
+ !Number.isSafeInteger(ref.size) ||
30
+ ref.size < 1 ||
31
+ typeof ref.sha256 !== "string" ||
32
+ !SHA.test(ref.sha256))
33
+ return fail(`invalid resources.${kind}`);
34
+ }
35
+ if (value.index.size > 8 * 1024 * 1024)
36
+ return fail("resource index exceeds 8 MiB compressed limit");
37
+ return value;
38
+ }
39
+ export function parseResourceIndex(bytes, refs, expected) {
40
+ if (bytes.length !== refs.index.size || sha256Bytes(bytes) !== refs.index.sha256)
41
+ return fail("resource index length/SHA-256 mismatch");
42
+ let raw;
43
+ try {
44
+ raw = JSON.parse(gunzipSync(bytes, { maxOutputLength: 64 * 1024 * 1024 }).toString("utf8"));
45
+ }
46
+ catch {
47
+ return fail("invalid or oversized compressed resource index");
48
+ }
49
+ if (!record(raw) ||
50
+ Object.keys(raw).some((k) => !["schemaVersion", "version", "tier", "artifactSha256", "bundle", "files"].includes(k)) ||
51
+ raw.schemaVersion !== 1 ||
52
+ raw.version !== expected.version ||
53
+ raw.tier !== expected.tier ||
54
+ raw.artifactSha256 !== expected.artifactSha256 ||
55
+ !record(raw.bundle) ||
56
+ Object.keys(raw.bundle).some((k) => !["filename", "size", "sha256"].includes(k)) ||
57
+ raw.bundle.filename !== refs.bundle.filename ||
58
+ raw.bundle.sha256 !== refs.bundle.sha256 ||
59
+ raw.bundle.size !== refs.bundle.size ||
60
+ !record(raw.files))
61
+ return fail("resource index release identity mismatch");
62
+ if (Object.keys(raw.files).length > 250000)
63
+ return fail("too many indexed resources");
64
+ const folded = new Set();
65
+ const intervals = [];
66
+ for (const [name, value] of Object.entries(raw.files)) {
67
+ if (!safe(name) || folded.has(name.toLowerCase()) || !record(value))
68
+ return fail(`unsafe/duplicate indexed path: ${name}`);
69
+ folded.add(name.toLowerCase());
70
+ if (Object.keys(value).some((k) => !["offset", "compressedSize", "compressedSha256", "size", "sha256", "requires"].includes(k)) ||
71
+ ![value.offset, value.compressedSize, value.size].every((n) => typeof n === "number" && Number.isSafeInteger(n)) ||
72
+ value.offset < 0 ||
73
+ value.compressedSize < 1 ||
74
+ value.size < 0 ||
75
+ value.size > 32 * 1024 * 1024 ||
76
+ typeof value.sha256 !== "string" ||
77
+ !SHA.test(value.sha256) ||
78
+ typeof value.compressedSha256 !== "string" ||
79
+ !SHA.test(value.compressedSha256) ||
80
+ !Array.isArray(value.requires) ||
81
+ value.requires.length > 10000 ||
82
+ value.requires.some((d) => typeof d !== "string" ||
83
+ !safe(d) ||
84
+ d.split("/")[0] !== name.split("/")[0] ||
85
+ !Object.hasOwn(raw.files, d)))
86
+ return fail(`invalid indexed resource: ${name}`);
87
+ const end = value.offset + value.compressedSize;
88
+ if (!Number.isSafeInteger(end) || end > refs.bundle.size)
89
+ return fail(`resource range escapes bundle: ${name}`);
90
+ intervals.push([value.offset, end]);
91
+ }
92
+ for (const name of folded) {
93
+ const parts = name.split("/");
94
+ for (let i = 1; i < parts.length; i++)
95
+ if (folded.has(parts.slice(0, i).join("/")))
96
+ return fail(`indexed file/directory collision: ${name}`);
97
+ }
98
+ intervals.sort((a, b) => a[0] - b[0]);
99
+ if (intervals[0]?.[0] !== 0 ||
100
+ intervals.at(-1)?.[1] !== refs.bundle.size ||
101
+ intervals.some((interval, i) => i > 0 && interval[0] !== intervals[i - 1][1]))
102
+ return fail("resource ranges overlap or leave unindexed bytes");
103
+ return raw;
104
+ }
105
+ export const EMPTY_DOWNLOAD_SELECTION_MESSAGE = "config.icons must contain at least one icon ID before install/update; edit icons in moeicons.config.json or run moeicons init";
106
+ export function assertDownloadSelection(config) {
107
+ if (config.icons.length === 0)
108
+ throw new CliError("VALIDATION_ERROR", EMPTY_DOWNLOAD_SELECTION_MESSAGE);
109
+ }
110
+ export function planSelectedResources(config, catalog, index) {
111
+ assertDownloadSelection(config);
112
+ if (config.tier !== index.tier)
113
+ return fail(`config.tier=${config.tier} does not match resource tier=${index.tier}`);
114
+ const chosen = new Set();
115
+ const fallbacks = new Set();
116
+ const visit = (name) => {
117
+ const pending = [name];
118
+ while (pending.length) {
119
+ const path = pending.pop();
120
+ if (chosen.has(path))
121
+ continue;
122
+ const entry = index.files[path];
123
+ if (!entry)
124
+ return fail(`configured resource is missing from this release: ${path}; check icons/themes/target or choose another fixed release`);
125
+ chosen.add(path);
126
+ if (chosen.size > 20000)
127
+ return fail("selected resources exceed 20,000 files; reduce icons/themes or explicitly use downloadMode=full");
128
+ pending.push(...entry.requires);
129
+ }
130
+ };
131
+ if (config.target !== "assets")
132
+ visit(`${config.target}/types.d.ts`);
133
+ let assets = config.target === "assets" || config.target === "vanilla";
134
+ for (const id of config.icons)
135
+ for (const requested of Object.keys(config.themes)) {
136
+ const actual = resolveIconTheme(config, catalog, requested, id);
137
+ if (!actual)
138
+ return fail(`icons[${id}]: no selected variant for themes.${requested}; register/select a variant or change missingIconPolicy`);
139
+ if (actual !== requested)
140
+ fallbacks.add(`${id}: ${requested} -> ${actual}`);
141
+ const theme = config.themes[actual];
142
+ const group = findCatalogStyleGroup(theme.styleGroup, catalog);
143
+ if (group.type === "bitmap") {
144
+ if (config.target === "vanilla")
145
+ return fail(`themes.${actual}: Vanilla does not support bitmap icons; choose assets/react/vue`);
146
+ const variant = resolveResourceVariant(group.id, {
147
+ ...(theme.format ? { format: theme.format } : {}),
148
+ ...(theme.imageSize ? { imageSize: theme.imageSize } : {}),
149
+ });
150
+ visit(`assets/${variant.resourceVariantId}/${id}.${variant.format}`);
151
+ assets = true;
152
+ }
153
+ else if (config.target === "assets") {
154
+ visit(`assets/${group.id}/${id}.svg`);
155
+ assets = true;
156
+ }
157
+ else {
158
+ const base = `${config.target}/${group.id}/${toProxyName(id)}${config.target === "vue" ? ".vue" : ""}`;
159
+ visit(`${base}.js`);
160
+ visit(`${base}.d.ts`);
161
+ if (config.target === "vanilla") {
162
+ visit(`assets/${group.id}/${id}.svg`);
163
+ assets = true;
164
+ }
165
+ }
166
+ }
167
+ if (assets)
168
+ visit("assets/manifest.json");
169
+ const paths = [...chosen].sort();
170
+ const payloadBytes = paths.reduce((sum, p) => sum + index.files[p].compressedSize, 0);
171
+ const expandedBytes = paths.reduce((sum, p) => sum + index.files[p].size, 0);
172
+ if (paths.length > 20000 || expandedBytes > 512 * 1024 * 1024 || payloadBytes > 512 * 1024 * 1024)
173
+ return fail("selected resources exceed 20,000 files/512 MiB budget; reduce icons/themes or explicitly use downloadMode=full");
174
+ return { paths, payloadBytes, expandedBytes, fallbacks: [...fallbacks].sort() };
175
+ }
176
+ export function verifyResource(bytes, entry, name) {
177
+ if (bytes.length !== entry.compressedSize || sha256Bytes(bytes) !== entry.compressedSha256)
178
+ return fail(`compressed resource length/SHA-256 mismatch: ${name}`);
179
+ let expanded;
180
+ try {
181
+ expanded = gunzipSync(bytes, { maxOutputLength: Math.max(1, entry.size) });
182
+ }
183
+ catch {
184
+ return fail(`invalid compressed resource: ${name}`);
185
+ }
186
+ if (expanded.length !== entry.size || sha256Bytes(expanded) !== entry.sha256)
187
+ return fail(`resource length/SHA-256 mismatch: ${name}`);
188
+ return expanded;
189
+ }
190
+ export async function downloadSelectedResources(index, indexSha256, paths, deps) {
191
+ const files = {};
192
+ let networkBytes = 0;
193
+ let cacheHits = 0;
194
+ let cursor = 0;
195
+ let urlPromise;
196
+ let failure;
197
+ const controller = new AbortController();
198
+ const abort = () => controller.abort();
199
+ deps.signal.addEventListener("abort", abort, { once: true });
200
+ if (deps.signal.aborted)
201
+ controller.abort();
202
+ const getUrl = () => (urlPromise ??= deps.getBundleUrl());
203
+ try {
204
+ await Promise.all(Array.from({ length: Math.min(4, paths.length) }, async () => {
205
+ while (cursor < paths.length && !failure) {
206
+ const name = paths[cursor++];
207
+ const entry = index.files[name];
208
+ const cached = join(deps.cacheDir, "resources", index.tier, index.version, indexSha256, entry.compressedSha256);
209
+ try {
210
+ if (controller.signal.aborted)
211
+ throw new CliError("CANCELLED", "selected resource download cancelled");
212
+ if (deps.io.existsSync(cached) && deps.io.readFileSync) {
213
+ try {
214
+ files[name] = verifyResource(deps.io.readFileSync(cached), entry, name);
215
+ cacheHits++;
216
+ continue;
217
+ }
218
+ catch {
219
+ /* corrupt cache is never used */
220
+ }
221
+ }
222
+ if (deps.offline)
223
+ throw new CliError("NETWORK_ERROR", `offline resource is missing or corrupt: ${name}; reconnect and run moeicons install, then retry offline`);
224
+ let bytes;
225
+ for (let attempt = 0; attempt < 3; attempt++) {
226
+ if (controller.signal.aborted)
227
+ throw new CliError("CANCELLED", "selected resource download cancelled");
228
+ try {
229
+ if (deps.readRange)
230
+ bytes = await deps.readRange(entry.offset, entry.compressedSize);
231
+ else {
232
+ let url = new URL(await getUrl());
233
+ let response;
234
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30000)]);
235
+ for (let redirects = 0; redirects <= 5; redirects++) {
236
+ const loopback = deps.allowLoopback &&
237
+ url.protocol === "http:" &&
238
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
239
+ if ((url.protocol !== "https:" && !loopback) ||
240
+ url.username ||
241
+ url.password ||
242
+ !deps.allowedHosts.includes(url.host))
243
+ return fail("resource URL uses an untrusted host or protocol");
244
+ response = await deps.fetch(url, {
245
+ headers: {
246
+ Range: `bytes=${entry.offset}-${entry.offset + entry.compressedSize - 1}`,
247
+ "Accept-Encoding": "identity",
248
+ },
249
+ redirect: "manual",
250
+ signal,
251
+ });
252
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
253
+ const location = response.headers.get("location");
254
+ await response.body?.cancel();
255
+ if (!location || redirects === 5)
256
+ return fail("resource redirect limit exceeded");
257
+ url = new URL(location, url);
258
+ continue;
259
+ }
260
+ break;
261
+ }
262
+ if (!response)
263
+ throw new Error("resource response unavailable");
264
+ if ([401, 403].includes(response.status)) {
265
+ await response.body?.cancel();
266
+ if (attempt === 2)
267
+ throw new CliError("AUTH_ERROR", "resource URL expired or access revoked; retry moeicons install/login");
268
+ urlPromise = undefined;
269
+ continue;
270
+ }
271
+ if ([429, 500, 502, 503, 504].includes(response.status)) {
272
+ await response.body?.cancel();
273
+ throw new Error(`resource service returned ${response.status}`);
274
+ }
275
+ if (response.status !== 206 ||
276
+ response.headers.get("content-range") !==
277
+ `bytes ${entry.offset}-${entry.offset + entry.compressedSize - 1}/${index.bundle.size}` ||
278
+ (response.headers.get("content-encoding") &&
279
+ response.headers.get("content-encoding") !== "identity")) {
280
+ await response.body?.cancel();
281
+ return fail(`server did not honor the exact resource Range for ${name}; retry or explicitly set downloadMode=full`);
282
+ }
283
+ const length = response.headers.get("content-length");
284
+ if (length && Number(length) !== entry.compressedSize) {
285
+ await response.body?.cancel();
286
+ return fail(`resource response length mismatch: ${name}`);
287
+ }
288
+ const reader = response.body?.getReader();
289
+ if (!reader)
290
+ return fail("resource response has no body");
291
+ const chunks = [];
292
+ let lengthRead = 0;
293
+ try {
294
+ for (;;) {
295
+ const part = await reader.read();
296
+ if (part.done)
297
+ break;
298
+ lengthRead += part.value.length;
299
+ if (lengthRead > entry.compressedSize) {
300
+ await reader.cancel();
301
+ return fail(`oversized resource response: ${name}`);
302
+ }
303
+ chunks.push(part.value);
304
+ }
305
+ }
306
+ finally {
307
+ reader.releaseLock();
308
+ }
309
+ bytes = Buffer.concat(chunks);
310
+ networkBytes += lengthRead;
311
+ }
312
+ break;
313
+ }
314
+ catch (error) {
315
+ if (error instanceof CliError || attempt === 2 || controller.signal.aborted)
316
+ throw error;
317
+ await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
318
+ }
319
+ }
320
+ if (!bytes)
321
+ throw new CliError("NETWORK_ERROR", `resource download failed: ${name}`);
322
+ files[name] = verifyResource(bytes, entry, name);
323
+ cacheArtifact(deps.io, cached, bytes, entry.compressedSha256);
324
+ }
325
+ catch (error) {
326
+ const code = typeof error === "object" && error !== null && "code" in error
327
+ ? error.code
328
+ : undefined;
329
+ failure ??=
330
+ error instanceof CliError
331
+ ? error
332
+ : code === "ENOSPC" || code === "EDQUOT"
333
+ ? new CliError("DISK_FULL", `not enough disk space for verified resource cache: ${name}; free space and retry; project files were not changed`)
334
+ : controller.signal.aborted
335
+ ? new CliError("CANCELLED", "selected resource download cancelled")
336
+ : code === "EACCES" || code === "EROFS" || code === "EISDIR"
337
+ ? new CliError("UNEXPECTED", `resource cache is not writable: ${name}; check MOEICONS_CACHE_DIR permissions`)
338
+ : new CliError("NETWORK_ERROR", `resource request failed after retries: ${name}; retry the same release; no full archive was downloaded`);
339
+ controller.abort();
340
+ }
341
+ }
342
+ }));
343
+ if (failure)
344
+ throw failure;
345
+ return { files, networkBytes, cacheHits };
346
+ }
347
+ finally {
348
+ deps.signal.removeEventListener("abort", abort);
349
+ }
350
+ }
@@ -4,9 +4,15 @@ export interface SignedArtifactDescriptor {
4
4
  readonly size: number;
5
5
  readonly sha256: string;
6
6
  }
7
+ export declare function parseSignedDescriptor(value: unknown, now: number, options?: {
8
+ readonly allowLoopback?: boolean;
9
+ }): SignedArtifactDescriptor;
7
10
  /**
8
11
  * Exchange an access token for a descriptor. The caller supplies only the
9
- * frozen API path; credentials can never be sent to another origin.
12
+ * frozen API path; credentials can never be sent to another origin. The
13
+ * descriptor request uses `redirect: "error"` — an API endpoint must never
14
+ * bounce us to an untrusted host mid-handshake (unlike the large signed-artifact
15
+ * download below, which follows a bounded number of CDN redirects).
10
16
  */
11
17
  export declare function fetchSignedArtifactDescriptor(apiPath: string, accessToken: string, deps?: {
12
18
  readonly fetch?: typeof fetch;
@@ -22,6 +28,7 @@ export declare function downloadSignedArtifact(descriptor: SignedArtifactDescrip
22
28
  readonly timeoutMs?: number;
23
29
  readonly maxRedirects?: number;
24
30
  readonly now?: number;
31
+ readonly allowLoopback?: boolean;
25
32
  readonly onProgress?: (event: {
26
33
  readonly downloadedBytes: number;
27
34
  readonly totalBytes?: number;
@@ -2,14 +2,21 @@ import { CliError } from "../errors/index.js";
2
2
  import { downloadArtifact, verifyArtifact } from "../project/install.js";
3
3
  const API_ORIGIN = "https://api.moeicons.com";
4
4
  const SHA256 = /^[a-f0-9]{64}$/;
5
- function parseDescriptor(value, now) {
5
+ function isLoopback(hostname) {
6
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
7
+ }
8
+ export function parseSignedDescriptor(value, now, options = {}) {
6
9
  if (typeof value !== "object" || value === null || Array.isArray(value))
7
10
  throw new CliError("VALIDATION_ERROR", "invalid signed artifact descriptor");
8
11
  const item = value;
9
12
  if (Object.keys(item).some((key) => !["url", "expiresAt", "size", "sha256"].includes(key)) ||
10
- typeof item.url !== "string" || typeof item.expiresAt !== "string" ||
11
- typeof item.size !== "number" || !Number.isSafeInteger(item.size) || item.size < 1 ||
12
- typeof item.sha256 !== "string" || !SHA256.test(item.sha256)) {
13
+ typeof item.url !== "string" ||
14
+ typeof item.expiresAt !== "string" ||
15
+ typeof item.size !== "number" ||
16
+ !Number.isSafeInteger(item.size) ||
17
+ item.size < 1 ||
18
+ typeof item.sha256 !== "string" ||
19
+ !SHA256.test(item.sha256)) {
13
20
  throw new CliError("VALIDATION_ERROR", "invalid signed artifact descriptor");
14
21
  }
15
22
  let url;
@@ -20,13 +27,18 @@ function parseDescriptor(value, now) {
20
27
  throw new CliError("VALIDATION_ERROR", "invalid signed artifact URL");
21
28
  }
22
29
  const expires = Date.parse(item.expiresAt);
23
- if (url.protocol !== "https:" || !Number.isFinite(expires) || expires <= now)
30
+ const loopbackHttp = options.allowLoopback === true && url.protocol === "http:" && isLoopback(url.hostname);
31
+ if ((url.protocol !== "https:" && !loopbackHttp) || !Number.isFinite(expires) || expires <= now) {
24
32
  throw new CliError("VALIDATION_ERROR", "signed artifact descriptor is expired or insecure");
33
+ }
25
34
  return item;
26
35
  }
27
36
  /**
28
37
  * Exchange an access token for a descriptor. The caller supplies only the
29
- * frozen API path; credentials can never be sent to another origin.
38
+ * frozen API path; credentials can never be sent to another origin. The
39
+ * descriptor request uses `redirect: "error"` — an API endpoint must never
40
+ * bounce us to an untrusted host mid-handshake (unlike the large signed-artifact
41
+ * download below, which follows a bounded number of CDN redirects).
30
42
  */
31
43
  export async function fetchSignedArtifactDescriptor(apiPath, accessToken, deps = {}) {
32
44
  const url = new URL(apiPath, API_ORIGIN);
@@ -40,12 +52,18 @@ export async function fetchSignedArtifactDescriptor(apiPath, accessToken, deps =
40
52
  deps.signal?.addEventListener("abort", abort, { once: true });
41
53
  try {
42
54
  const response = await (deps.fetch ?? fetch)(url, {
43
- method: "GET", redirect: "error", signal: controller.signal,
55
+ method: "GET",
56
+ redirect: "error",
57
+ signal: controller.signal,
44
58
  headers: { accept: "application/json", authorization: `Bearer ${accessToken}` },
45
59
  });
46
60
  if (!response.ok)
47
- throw new CliError(response.status === 401 ? "AUTH_ERROR" : response.status === 403 ? "FORBIDDEN" : "NETWORK_ERROR", `pro descriptor request failed with ${response.status}`);
48
- return parseDescriptor(await response.json(), deps.now ?? Date.now());
61
+ throw new CliError(response.status === 401
62
+ ? "AUTH_ERROR"
63
+ : response.status === 403
64
+ ? "FORBIDDEN"
65
+ : "NETWORK_ERROR", `pro descriptor request failed with ${response.status}`);
66
+ return parseSignedDescriptor(await response.json(), deps.now ?? Date.now());
49
67
  }
50
68
  catch (error) {
51
69
  if (error instanceof CliError)
@@ -59,17 +77,23 @@ export async function fetchSignedArtifactDescriptor(apiPath, accessToken, deps =
59
77
  }
60
78
  /** Download signed bytes without forwarding API credentials or cookies. */
61
79
  export async function downloadSignedArtifact(descriptor, options) {
62
- const checked = parseDescriptor(descriptor, options.now ?? Date.now());
80
+ if (options.signal?.aborted)
81
+ throw new CliError("CANCELLED", "signed resource download cancelled");
82
+ const checked = parseSignedDescriptor(descriptor, options.now ?? Date.now(), options);
63
83
  const result = await downloadArtifact(checked.url, {
64
84
  maxBytes: checked.size,
65
- timeoutMs: options.timeoutMs ?? 30_000,
85
+ timeoutMs: options.timeoutMs ??
86
+ Math.min(600_000, Math.max(30_000, Math.ceil(checked.size / (128 * 1024)) * 1_000 + 15_000)),
66
87
  maxRedirects: options.maxRedirects ?? 5,
67
88
  allowedHosts: options.allowedHosts,
89
+ ...(options.allowLoopback ? { allowHttpLoopback: true } : {}),
68
90
  ...(options.onProgress ? { onProgress: options.onProgress } : {}),
69
91
  }, {
70
92
  ...(options.fetch ? { fetchFn: options.fetch } : {}),
71
93
  ...(options.signal ? { signal: options.signal } : {}),
72
94
  });
95
+ if (options.signal?.aborted)
96
+ throw new CliError("CANCELLED", "signed resource download cancelled");
73
97
  if (!result.ok)
74
98
  throw new CliError(result.code === "NETWORK_ERROR" ? "NETWORK_ERROR" : "VALIDATION_ERROR", result.message);
75
99
  if (result.bytes.byteLength !== checked.size)