@moewolf/moe-icons-cli 0.0.2 → 0.0.4

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.
@@ -56,6 +56,7 @@ export type FreeDownloadSuccess = {
56
56
  readonly descriptor: ReleaseDescriptor;
57
57
  readonly descriptorSha256: string;
58
58
  readonly artifactBytes: Uint8Array;
59
+ readonly archiveFiles?: Readonly<Record<string, Uint8Array>>;
59
60
  readonly selected?: SelectedResourceDownload;
60
61
  readonly downloadNote?: string;
61
62
  readonly catalogJson: string;
@@ -6,7 +6,7 @@ import { validateConfigDocument, } from "../project/config.js";
6
6
  import { join } from "node:path";
7
7
  import { createHash } from "node:crypto";
8
8
  import { downloadArtifact, verifyArtifact } from "../project/install.js";
9
- import { decodeUtf8, extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
9
+ import { decodeUtf8, extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
10
10
  import { catalog as bundledCatalog } from "../catalog/catalog.js";
11
11
  import { cacheArtifact } from "./cache.js";
12
12
  import { extractAndVerifyMetadataArchive } from "../metadata/archive.js";
@@ -65,6 +65,8 @@ function mapDownloadError(code, message) {
65
65
  if (code === "HTTP_ERROR" && / 404$/.test(message)) {
66
66
  return { ok: false, reason: "not-found", message };
67
67
  }
68
+ if (code === "CANCELLED")
69
+ return { ok: false, reason: "cancelled", message };
68
70
  if (code === "NETWORK_ERROR")
69
71
  return { ok: false, reason: "network", message };
70
72
  return { ok: false, reason: "validation", message };
@@ -148,12 +150,14 @@ async function loadBytes(io, filename, limits, tag) {
148
150
  message: `download failed after ${MAX_DOWNLOAD_ATTEMPTS} attempts`,
149
151
  };
150
152
  }
151
- function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
152
- const unpacked = extractTarGz(artifactBytes, {
153
+ async function catalogFromArchive(artifactBytes, catalogFilename, expectedSha, signal) {
154
+ const unpacked = await extractTarGzAsync(artifactBytes, {
153
155
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
154
156
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
155
- });
157
+ }, signal);
156
158
  if (unpacked.errors.length > 0) {
159
+ if (signal?.aborted)
160
+ return { ok: false, reason: "cancelled", message: "archive extraction cancelled" };
157
161
  return { ok: false, reason: "validation", message: unpacked.errors[0] ?? "extract failed" };
158
162
  }
159
163
  const catalogBytes = unpacked.files[catalogFilename] ?? unpacked.files[`./${catalogFilename}`];
@@ -168,7 +172,7 @@ function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
168
172
  message: `catalog SHA-256 mismatch: expected ${expectedSha}, got ${actual}`,
169
173
  };
170
174
  }
171
- return { ok: true, json: decodeUtf8(catalogBytes) };
175
+ return { ok: true, json: decodeUtf8(catalogBytes), files: unpacked.files };
172
176
  }
173
177
  /**
174
178
  * Download the small metadata archive, verify the whole-archive SHA-256 plus
@@ -489,7 +493,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
489
493
  const cached = io.readFileSync(artifactCache);
490
494
  const verified = verifyArtifact(cached, descriptor.free.sha256);
491
495
  if (verified.ok) {
492
- const catalog = catalogFromArchive(cached, descriptor.catalog.filename, descriptor.catalog.sha256);
496
+ const catalog = await catalogFromArchive(cached, descriptor.catalog.filename, descriptor.catalog.sha256, io.signal);
493
497
  if (!catalog.ok)
494
498
  return catalog;
495
499
  const metadata = preflightMetadata
@@ -503,6 +507,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
503
507
  descriptorSha256,
504
508
  artifactBytes: cached,
505
509
  catalogJson: catalog.json,
510
+ archiveFiles: catalog.files,
506
511
  cacheHit: true,
507
512
  tag,
508
513
  metadataSha256: metadata.value.metadataSha256,
@@ -533,7 +538,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
533
538
  message: `free artifact SHA-256 mismatch: expected ${descriptor.free.sha256}, got ${verified.actual}`,
534
539
  };
535
540
  }
536
- const catalog = catalogFromArchive(artifact.bytes, descriptor.catalog.filename, descriptor.catalog.sha256);
541
+ const catalog = await catalogFromArchive(artifact.bytes, descriptor.catalog.filename, descriptor.catalog.sha256, io.signal);
537
542
  if (!catalog.ok)
538
543
  return catalog;
539
544
  const metadata = preflightMetadata
@@ -553,6 +558,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
553
558
  descriptorSha256,
554
559
  artifactBytes: artifact.bytes,
555
560
  catalogJson: catalog.json,
561
+ archiveFiles: catalog.files,
556
562
  cacheHit: false,
557
563
  tag,
558
564
  metadataSha256: metadata.value.metadataSha256,
@@ -46,13 +46,13 @@ export declare function loadInstalledCatalog(projectRoot: string, fs_: Pick<Tran
46
46
  * Vanilla (and bitmap themes on react/vue) need the full cached archive so
47
47
  * `assets/` is available — the installed package subtree alone is not enough.
48
48
  */
49
- export declare function loadArchiveFiles(projectRoot: string, env: Readonly<Record<string, string | undefined>>, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync" | "readdirSync">, injected?: Readonly<Record<string, Uint8Array>>): {
49
+ export declare function loadArchiveFiles(projectRoot: string, env: Readonly<Record<string, string | undefined>>, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync" | "readdirSync">, injected?: Readonly<Record<string, Uint8Array>>, signal?: AbortSignal): Promise<{
50
50
  readonly ok: true;
51
51
  readonly files: Readonly<Record<string, Uint8Array>>;
52
52
  } | {
53
53
  readonly ok: false;
54
54
  readonly reason: string;
55
- };
55
+ }>;
56
56
  export declare function runGenerateUseCase(context: CommandContext, fs_: TransactionalFsWithCopy, options?: {
57
57
  readonly noTailwind?: boolean;
58
58
  readonly target?: Target;
@@ -8,7 +8,7 @@ import { toProxyName } from "./icon-names.js";
8
8
  import { planGeneratedFiles } from "../generator/generate.js";
9
9
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
10
10
  import { isCliError } from "../errors/index.js";
11
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
11
+ import { extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
12
12
  import { artifactCachePath } from "./free-download.js";
13
13
  import { resolveThemes } from "../generator/theme-resolve.js";
14
14
  import { loadPinnedBitmapShardAssets } from "./bitmap-shard-resolver.js";
@@ -238,7 +238,7 @@ function mergePinnedBitmapShardAssets(archiveFiles, effectiveConfig, sourceCatal
238
238
  * Vanilla (and bitmap themes on react/vue) need the full cached archive so
239
239
  * `assets/` is available — the installed package subtree alone is not enough.
240
240
  */
241
- export function loadArchiveFiles(projectRoot, env, fs_, injected) {
241
+ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal) {
242
242
  if (injected)
243
243
  return { ok: true, files: injected };
244
244
  const fixtureTgz = env.MOEICONS_BITMAP_ARCHIVE;
@@ -249,10 +249,10 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
249
249
  return { ok: false, reason: "MOEICONS_BITMAP_ARCHIVE is only honored in a local-test context" };
250
250
  }
251
251
  if (fixtureTgz && fs_.existsSync(fixtureTgz)) {
252
- const unpacked = extractTarGz(readBinaryFile(fs_, fixtureTgz), {
252
+ const unpacked = await extractTarGzAsync(readBinaryFile(fs_, fixtureTgz), {
253
253
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
254
254
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
255
- });
255
+ }, signal);
256
256
  if (unpacked.errors.length > 0)
257
257
  return { ok: false, reason: unpacked.errors[0] ?? "invalid bitmap archive fixture" };
258
258
  return { ok: true, files: unpacked.files };
@@ -320,10 +320,10 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
320
320
  if (meta?.artifactVersion && meta.artifactSha256) {
321
321
  const cached = artifactCachePath(cacheDir, meta.artifactVersion, meta.artifactSha256);
322
322
  if (fs_.existsSync(cached)) {
323
- const unpacked = extractTarGz(readBinaryFile(fs_, cached), {
323
+ const unpacked = await extractTarGzAsync(readBinaryFile(fs_, cached), {
324
324
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
325
325
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
326
- });
326
+ }, signal);
327
327
  if (unpacked.errors.length > 0)
328
328
  return { ok: false, reason: unpacked.errors[0] ?? "invalid cached artifact" };
329
329
  return { ok: true, files: unpacked.files };
@@ -371,7 +371,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
371
371
  }
372
372
  let archiveFiles = options.archiveFiles;
373
373
  if (needsArchiveFiles(effectiveConfig, sourceCatalog) && archiveFiles === undefined) {
374
- const loadedArchive = loadArchiveFiles(project.root, context.env, fs_, undefined);
374
+ const loadedArchive = await loadArchiveFiles(project.root, context.env, fs_, undefined, context.signal);
375
375
  if (!loadedArchive.ok) {
376
376
  return {
377
377
  ok: false,
@@ -6,7 +6,7 @@ import { CliError, isCliError } from "../errors/index.js";
6
6
  import { bundledSourceVersion, downloadFreeRelease } from "./free-download.js";
7
7
  import { serializeInstallMetadata, sha256Bytes } from "../project/install-metadata.js";
8
8
  import { withProjectLock } from "../project/project-lock.js";
9
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
9
+ import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
10
10
  import { parseCatalog } from "../catalog/catalog.js";
11
11
  import { posix } from "node:path";
12
12
  /**
@@ -118,7 +118,9 @@ export async function runInstallUseCase(context, deps, options) {
118
118
  : undefined;
119
119
  const subtree = selectedTarget
120
120
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
121
- : selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor.free, target);
121
+ : downloaded.archiveFiles
122
+ ? selectTargetSubtreeFromFiles(downloaded.archiveFiles, downloaded.descriptor.free, target)
123
+ : await selectTargetSubtreeAsync(downloaded.artifactBytes, downloaded.descriptor.free, target, context.signal);
122
124
  if (!subtree.ok) {
123
125
  return subtree.reason === "checksum-mismatch"
124
126
  ? { ok: false, reason: "checksum-mismatch", message: subtree.message }
@@ -10,14 +10,14 @@ import { executeManagedReconcile } from "../project/install.js";
10
10
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
11
11
  import { withProjectLock } from "../project/project-lock.js";
12
12
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
13
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
13
+ import { extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
14
14
  import { artifactCachePath, downloadFreeRelease, metadataCachePath, } from "./free-download.js";
15
15
  import { downloadProArtifact } from "./pro-download.js";
16
16
  import { PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint } from "./pro-download.js";
17
17
  import { runAccessTokenUseCase } from "./auth.js";
18
18
  import { resolveBitmapTuples, resolveConfiguredBitmapShards } from "./bitmap-shard-resolver.js";
19
19
  import { mergeBitmapShardCacheManifest, readBitmapShardCacheManifest, writeBitmapShardCacheManifest, } from "./bitmap-shard-cache.js";
20
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
20
+ import { configuredComponentFiles, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
21
21
  export async function runLibraryUpdateUseCase(context, deps, expected) {
22
22
  const project = detectProject(context.cwd);
23
23
  if (!project)
@@ -31,6 +31,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
31
31
  assertDownloadSelection(bootstrap.config);
32
32
  let selected;
33
33
  let catalogJson;
34
+ let fullArchiveFiles;
34
35
  let archiveBytes;
35
36
  let artifactSha256;
36
37
  let catalogSha256;
@@ -58,6 +59,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
58
59
  selected = downloaded.selected;
59
60
  catalogJson = downloaded.catalogJson;
60
61
  archiveBytes = downloaded.artifactBytes;
62
+ fullArchiveFiles = downloaded.archiveFiles;
61
63
  artifactSha256 = downloaded.descriptor.free.sha256;
62
64
  catalogSha256 = downloaded.descriptor.catalog.sha256;
63
65
  manifestJson = downloaded.manifestJson;
@@ -74,6 +76,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
74
76
  selected = downloaded.selected;
75
77
  catalogJson = downloaded.catalogJson;
76
78
  archiveBytes = downloaded.artifactBytes;
79
+ fullArchiveFiles = downloaded.archiveFiles;
77
80
  artifactSha256 = downloaded.descriptor.sha256;
78
81
  catalogSha256 = downloaded.descriptor.catalogSha256;
79
82
  manifestJson = downloaded.manifestJson;
@@ -114,10 +117,10 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
114
117
  : undefined;
115
118
  const unpacked = selected
116
119
  ? { files: selected.files, errors: [] }
117
- : extractTarGz(archiveBytes, {
120
+ : fullArchiveFiles ? { files: fullArchiveFiles, errors: [] } : await extractTarGzAsync(archiveBytes, {
118
121
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
119
122
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
120
- });
123
+ }, context.signal);
121
124
  if (unpacked.errors.length)
122
125
  throw new CliError("VALIDATION_ERROR", unpacked.errors[0] ?? "invalid artifact");
123
126
  const target = loaded.config.target;
@@ -128,7 +131,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
128
131
  : undefined;
129
132
  const subtree = selectedTarget
130
133
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
131
- : selectTargetSubtree(archiveBytes, tierSource, target);
134
+ : selectTargetSubtreeFromFiles(unpacked.files, tierSource, target);
132
135
  if (!subtree.ok)
133
136
  throw new CliError("VALIDATION_ERROR", subtree.message);
134
137
  const archiveFiles = { ...unpacked.files };
@@ -83,6 +83,7 @@ export declare function downloadProArtifact(context: CommandContext, auth: AuthU
83
83
  }): Promise<{
84
84
  readonly descriptor: ProArtifactDescriptor;
85
85
  readonly artifactBytes: Uint8Array;
86
+ readonly archiveFiles?: Readonly<Record<string, Uint8Array>>;
86
87
  readonly catalogJson: string;
87
88
  readonly manifestJson: string;
88
89
  readonly manualMd: string;
@@ -3,7 +3,7 @@ import { downloadProSelected } from "./pro-selected-download.js";
3
3
  import { validateConfigDocument, } from "../project/config.js";
4
4
  import { parseCatalog } from "../catalog/catalog.js";
5
5
  import { CliError } from "../errors/index.js";
6
- import { extractTarGz, decodeUtf8, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
6
+ import { extractTarGzAsync, decodeUtf8, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
7
7
  import { sha256Bytes } from "../project/install-metadata.js";
8
8
  import { downloadSignedArtifact, parseSignedDescriptor, } from "./signed-artifact.js";
9
9
  import { runAccessTokenUseCase } from "./auth.js";
@@ -310,10 +310,12 @@ export async function downloadProArtifact(context, auth, expected, deps = {}) {
310
310
  size: descriptor.size,
311
311
  sha256: descriptor.sha256,
312
312
  }, downloadOptions);
313
- const extracted = extractTarGz(artifactBytes, {
313
+ const extracted = await extractTarGzAsync(artifactBytes, {
314
314
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
315
315
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
316
- });
316
+ }, context.signal);
317
+ if (context.signal.aborted)
318
+ throw new CliError("CANCELLED", "archive extraction cancelled");
317
319
  if (extracted.errors.length > 0)
318
320
  throw new CliError("VALIDATION_ERROR", extracted.errors[0] ?? "invalid pro archive");
319
321
  const catalog = extracted.files[descriptor.catalogFilename] ??
@@ -323,6 +325,7 @@ export async function downloadProArtifact(context, auth, expected, deps = {}) {
323
325
  return {
324
326
  descriptor,
325
327
  artifactBytes,
328
+ archiveFiles: extracted.files,
326
329
  catalogJson: decodeUtf8(catalog),
327
330
  manifestJson: metadata.manifestJson,
328
331
  manualMd: metadata.manualMd,
@@ -7,7 +7,7 @@ import { withProjectLock } from "../project/project-lock.js";
7
7
  import { runAccessTokenUseCase } from "./auth.js";
8
8
  import { downloadProArtifact, PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint, } from "./pro-download.js";
9
9
  import { artifactCachePath, metadataCachePath } from "./free-download.js";
10
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
10
+ import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
11
11
  import { typesReexport } from "./install.js";
12
12
  import { CliError } from "../errors/index.js";
13
13
  import { catalog as bundledCatalog, parseCatalog } from "../catalog/catalog.js";
@@ -81,7 +81,9 @@ export async function runProInstallUseCase(context, deps, expected) {
81
81
  : undefined;
82
82
  const subtree = selectedTarget
83
83
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
84
- : selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor, target);
84
+ : downloaded.archiveFiles
85
+ ? selectTargetSubtreeFromFiles(downloaded.archiveFiles, downloaded.descriptor, target)
86
+ : await selectTargetSubtreeAsync(downloaded.artifactBytes, downloaded.descriptor, target, context.signal);
85
87
  if (!subtree.ok) {
86
88
  throw new CliError("VALIDATION_ERROR", subtree.message);
87
89
  }
@@ -55,3 +55,6 @@ export declare function computeSubtreeHash(files: Readonly<Record<string, Uint8A
55
55
  * verification for backward compatibility but still reject an empty subtree.
56
56
  */
57
57
  export declare function selectTargetSubtree(archiveBytes: Uint8Array, source: TargetSubtreeSource, target: Target): TargetSubtreeResult;
58
+ export declare function selectTargetSubtreeAsync(archiveBytes: Uint8Array, source: TargetSubtreeSource, target: Target, signal?: AbortSignal): Promise<TargetSubtreeResult>;
59
+ /** Reuse the same verified archive rather than inflating it again per target. */
60
+ export declare function selectTargetSubtreeFromFiles(archiveFiles: Readonly<Record<string, Uint8Array>>, source: TargetSubtreeSource, target: Target): TargetSubtreeResult;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES } from "../project/tar-gz.js";
2
+ import { extractTarGz, extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES } from "../project/tar-gz.js";
3
3
  import { posix } from "node:path";
4
4
  import { toProxyName } from "./icon-names.js";
5
5
  import { findCatalogStyleGroup } from "../catalog/catalog.js";
@@ -122,7 +122,17 @@ export function selectTargetSubtree(archiveBytes, source, target) {
122
122
  if (extracted.errors.length > 0) {
123
123
  return { ok: false, reason: "validation", message: extracted.errors[0] ?? "invalid archive" };
124
124
  }
125
- const files = subtreeFilesOf(extracted.files, target);
125
+ return selectTargetSubtreeFromFiles(extracted.files, source, target);
126
+ }
127
+ export async function selectTargetSubtreeAsync(archiveBytes, source, target, signal) {
128
+ const extracted = await extractTarGzAsync(archiveBytes, { maxEntries: ICON_ARCHIVE_MAX_ENTRIES, maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES }, signal);
129
+ if (extracted.errors.length)
130
+ return { ok: false, reason: "validation", message: extracted.errors[0] ?? "invalid archive" };
131
+ return selectTargetSubtreeFromFiles(extracted.files, source, target);
132
+ }
133
+ /** Reuse the same verified archive rather than inflating it again per target. */
134
+ export function selectTargetSubtreeFromFiles(archiveFiles, source, target) {
135
+ const files = subtreeFilesOf(archiveFiles, target);
126
136
  const computed = computeSubtreeHash(files);
127
137
  const modern = hasModernTargetContract(source);
128
138
  if (modern) {
@@ -109,13 +109,13 @@ function planViteReact(source, rel, specifier) {
109
109
  fixes: [],
110
110
  };
111
111
  }
112
- const { childStart, childEnd } = root;
112
+ const { childStart, childEnd, jsx } = root;
113
113
  const child = source.slice(childStart, childEnd);
114
114
  if (binding && new RegExp(`^\\s*<${escapeRegExp(binding)}(?:\\s|>)`).test(child)) {
115
115
  return { kind: "application", status: "ok", path: rel, candidates: [rel], evidence: ["render root is wrapped in the generated Provider"], fixes: [] };
116
116
  }
117
117
  const providerName = binding ?? "MoeiconsProvider";
118
- const after = source.slice(0, childStart) + `<${providerName}>${child}</${providerName}>` + source.slice(childEnd);
118
+ const after = source.slice(0, childStart) + `<${providerName}>${jsx ? child : `{${child}}`}</${providerName}>` + source.slice(childEnd);
119
119
  const imported = binding ? after : insertImport(after, `import { MoeiconsProvider } from "${specifier}";`, rel);
120
120
  const valid = parseSource(imported, rel);
121
121
  if (!valid.ok)
@@ -151,7 +151,7 @@ function findUniqueRender(ast) {
151
151
  node.arguments.length === 1) {
152
152
  const child = node.arguments[0];
153
153
  if (child && typeof child.start === "number" && typeof child.end === "number") {
154
- renders.push({ start: child.start, end: child.end });
154
+ renders.push({ start: child.start, end: child.end, jsx: child.type === "JSXElement" || child.type === "JSXFragment" });
155
155
  }
156
156
  }
157
157
  for (const value of Object.values(node)) {
@@ -168,7 +168,7 @@ function findUniqueRender(ast) {
168
168
  if (renders.length > 1)
169
169
  return "multiple";
170
170
  const only = renders[0];
171
- return only ? { childStart: only.start, childEnd: only.end } : undefined;
171
+ return only ? { childStart: only.start, childEnd: only.end, jsx: only.jsx } : undefined;
172
172
  }
173
173
  function insertImport(source, line, rel = "src/main.tsx") {
174
174
  if (source.includes(line))
@@ -0,0 +1,41 @@
1
+ export interface DownloadLimits {
2
+ readonly maxBytes: number;
3
+ readonly timeoutMs: number;
4
+ readonly maxRedirects: number;
5
+ readonly allowedHosts?: readonly string[];
6
+ readonly userAgent?: string;
7
+ readonly onProgress?: (event: {
8
+ readonly downloadedBytes: number;
9
+ readonly totalBytes?: number;
10
+ }) => void;
11
+ readonly allowHttpLoopback?: boolean;
12
+ }
13
+ export type DownloadResult = {
14
+ readonly ok: true;
15
+ readonly bytes: Uint8Array;
16
+ readonly finalUrl: string;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly code: string;
20
+ readonly message: string;
21
+ };
22
+ /**
23
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
24
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
25
+ * caller via the injected fetch/signal.
26
+ *
27
+ * Redirect policy: unlike the API descriptor handshake (which uses
28
+ * `redirect: "error"`), payload downloads intentionally follow a bounded number
29
+ * of redirects because code/metadata archives are served from CDNs that 3xx to
30
+ * a signed object host; the destination host is re-checked against the
31
+ * allowlist on every hop.
32
+ */
33
+ export declare function downloadArtifact(url: string, limits: DownloadLimits, deps?: {
34
+ fetchFn?: typeof fetch;
35
+ signal?: AbortSignal;
36
+ }): Promise<DownloadResult>;
37
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
38
+ export declare function verifyArtifact(bytes: Uint8Array, expectedSha256: string): {
39
+ ok: boolean;
40
+ actual: string;
41
+ };
@@ -0,0 +1,162 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, open, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
7
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
8
+ * caller via the injected fetch/signal.
9
+ *
10
+ * Redirect policy: unlike the API descriptor handshake (which uses
11
+ * `redirect: "error"`), payload downloads intentionally follow a bounded number
12
+ * of redirects because code/metadata archives are served from CDNs that 3xx to
13
+ * a signed object host; the destination host is re-checked against the
14
+ * allowlist on every hop.
15
+ */
16
+ export async function downloadArtifact(url, limits, deps = {}) {
17
+ if (deps.signal?.aborted)
18
+ return { ok: false, code: "CANCELLED", message: "download cancelled" };
19
+ const parsed = new URL(url);
20
+ const loopbackHttp = limits.allowHttpLoopback === true && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1");
21
+ if (parsed.protocol !== "https:" && !loopbackHttp) {
22
+ return { ok: false, code: "NON_HTTPS", message: "artifact URLs must use https" };
23
+ }
24
+ if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.host)) {
25
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `host ${parsed.host} not in allowlist` };
26
+ }
27
+ const fetchFn = deps.fetchFn ?? globalThis.fetch;
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
30
+ const abortHandler = () => controller.abort();
31
+ deps.signal?.addEventListener("abort", abortHandler, { once: true });
32
+ let response;
33
+ let temporary;
34
+ let file;
35
+ let reader;
36
+ try {
37
+ let currentUrl = url;
38
+ let redirects = 0;
39
+ for (;;) {
40
+ response = await fetchFn(currentUrl, {
41
+ method: "GET",
42
+ redirect: "manual",
43
+ signal: controller.signal,
44
+ ...(limits.userAgent ? { headers: { "user-agent": limits.userAgent } } : {}),
45
+ });
46
+ if (response.status >= 300 && response.status < 400) {
47
+ await response.body?.cancel();
48
+ redirects += 1;
49
+ if (redirects > limits.maxRedirects) {
50
+ return { ok: false, code: "TOO_MANY_REDIRECTS", message: `exceeded ${limits.maxRedirects} redirects` };
51
+ }
52
+ const location = response.headers.get("location");
53
+ if (!location) {
54
+ return { ok: false, code: "REDIRECT_NO_LOCATION", message: "redirect without location header" };
55
+ }
56
+ currentUrl = new URL(location, currentUrl).toString();
57
+ const next = new URL(currentUrl);
58
+ const nextLoopback = limits.allowHttpLoopback === true && next.protocol === "http:" && (next.hostname === "127.0.0.1" || next.hostname === "localhost" || next.hostname === "::1");
59
+ if (next.protocol !== "https:" && !nextLoopback) {
60
+ return { ok: false, code: "NON_HTTPS", message: "redirect to non-https url" };
61
+ }
62
+ if (limits.allowedHosts && !limits.allowedHosts.includes(next.host)) {
63
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `redirect host ${next.host} not in allowlist` };
64
+ }
65
+ continue;
66
+ }
67
+ break;
68
+ }
69
+ if (!response || response.status >= 400) {
70
+ return { ok: false, code: "HTTP_ERROR", message: `request failed with ${response.status ?? "unknown"}` };
71
+ }
72
+ const rawLength = response.headers.get("content-length");
73
+ const contentLength = rawLength !== null && /^\d+$/.test(rawLength) ? Number(rawLength) : undefined;
74
+ if (contentLength !== undefined && contentLength > limits.maxBytes) {
75
+ return { ok: false, code: "TOO_LARGE", message: `content-length ${contentLength} exceeds limit` };
76
+ }
77
+ const chunks = [];
78
+ let downloadedBytes = 0;
79
+ if (response.body) {
80
+ reader = response.body.getReader();
81
+ for (;;) {
82
+ const chunk = await reader.read();
83
+ if (chunk.done)
84
+ break;
85
+ const value = chunk.value;
86
+ downloadedBytes += value.byteLength;
87
+ if (downloadedBytes > limits.maxBytes) {
88
+ await reader.cancel();
89
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
90
+ }
91
+ if (!file && downloadedBytes > 8 * 1024 * 1024) {
92
+ temporary = await mkdtemp(join(tmpdir(), "moeicons-download-"));
93
+ file = await open(join(temporary, "bytes"), "wx", 0o600);
94
+ for (const previous of chunks)
95
+ await file.writeFile(previous);
96
+ chunks.length = 0;
97
+ }
98
+ if (file)
99
+ await file.writeFile(value);
100
+ else
101
+ chunks.push(value);
102
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
103
+ }
104
+ }
105
+ else {
106
+ const buffer = await response.arrayBuffer();
107
+ downloadedBytes = buffer.byteLength;
108
+ if (downloadedBytes > limits.maxBytes)
109
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
110
+ chunks.push(new Uint8Array(buffer));
111
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
112
+ }
113
+ if (file) {
114
+ await file.close();
115
+ file = undefined;
116
+ const bytes = await readFile(join(temporary, "bytes"));
117
+ if (controller.signal.aborted)
118
+ return { ok: false, code: deps.signal?.aborted ? "CANCELLED" : "NETWORK_ERROR", message: "download aborted" };
119
+ return { ok: true, bytes, finalUrl: currentUrl };
120
+ }
121
+ const bytes = new Uint8Array(downloadedBytes);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.byteLength;
126
+ }
127
+ return { ok: true, bytes, finalUrl: currentUrl };
128
+ }
129
+ catch (error) {
130
+ return {
131
+ ok: false,
132
+ code: controller.signal.aborted ? (deps.signal?.aborted ? "CANCELLED" : "NETWORK_ERROR") : "NETWORK_ERROR",
133
+ message: error instanceof Error ? error.message : String(error),
134
+ };
135
+ }
136
+ finally {
137
+ if (reader) {
138
+ try {
139
+ await reader.cancel();
140
+ }
141
+ catch { /* Stream may already be closed. */ }
142
+ reader.releaseLock();
143
+ }
144
+ else {
145
+ try {
146
+ await response?.body?.cancel();
147
+ }
148
+ catch { /* Redirect or error response. */ }
149
+ }
150
+ if (file)
151
+ await file.close().catch(() => undefined);
152
+ clearTimeout(timer);
153
+ deps.signal?.removeEventListener("abort", abortHandler);
154
+ if (temporary)
155
+ await rm(temporary, { recursive: true, force: true });
156
+ }
157
+ }
158
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
159
+ export function verifyArtifact(bytes, expectedSha256) {
160
+ const actual = createHash("sha256").update(bytes).digest("hex");
161
+ return { ok: actual.toLowerCase() === expectedSha256.toLowerCase(), actual };
162
+ }
@@ -92,44 +92,5 @@ export declare function executeGeneratedFilesDir(files: readonly {
92
92
  }[], projectRoot: string, outputDir: string, fs_: TransactionalFsWithCopy): void;
93
93
  /** Atomically create a new file without replacing an existing user file. */
94
94
  export declare function createFileIfAbsent(target: string, content: string, fs_: TransactionalFs): boolean;
95
- export interface DownloadLimits {
96
- readonly maxBytes: number;
97
- readonly timeoutMs: number;
98
- readonly maxRedirects: number;
99
- readonly allowedHosts?: readonly string[];
100
- readonly userAgent?: string;
101
- readonly onProgress?: (event: {
102
- readonly downloadedBytes: number;
103
- readonly totalBytes?: number;
104
- }) => void;
105
- readonly allowHttpLoopback?: boolean;
106
- }
107
- export type DownloadResult = {
108
- readonly ok: true;
109
- readonly bytes: Uint8Array;
110
- readonly finalUrl: string;
111
- } | {
112
- readonly ok: false;
113
- readonly code: string;
114
- readonly message: string;
115
- };
116
- /**
117
- * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
118
- * timeout, byte limit. Abort and temporary-file cleanup are handled by the
119
- * caller via the injected fetch/signal.
120
- *
121
- * Redirect policy: unlike the API descriptor handshake (which uses
122
- * `redirect: "error"`), payload downloads intentionally follow a bounded number
123
- * of redirects because code/metadata archives are served from CDNs that 3xx to
124
- * a signed object host; the destination host is re-checked against the
125
- * allowlist on every hop.
126
- */
127
- export declare function downloadArtifact(url: string, limits: DownloadLimits, deps?: {
128
- fetchFn?: typeof fetch;
129
- signal?: AbortSignal;
130
- }): Promise<DownloadResult>;
131
- /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
132
- export declare function verifyArtifact(bytes: Uint8Array, expectedSha256: string): {
133
- ok: boolean;
134
- actual: string;
135
- };
95
+ export { downloadArtifact, verifyArtifact } from "./download.js";
96
+ export type { DownloadLimits, DownloadResult } from "./download.js";