@moewolf/moe-icons-cli 0.0.3 → 0.0.5
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.
- package/bin/moeicons.js +10 -0
- package/dist/catalog/catalog.json +4 -4
- package/dist/core/free-download.d.ts +1 -0
- package/dist/core/free-download.js +13 -7
- package/dist/core/generate.d.ts +2 -2
- package/dist/core/generate.js +7 -7
- package/dist/core/install.js +4 -2
- package/dist/core/library-update.js +8 -5
- package/dist/core/pro-download.d.ts +1 -0
- package/dist/core/pro-download.js +6 -3
- package/dist/core/pro-install.js +4 -2
- package/dist/core/target-subtree.d.ts +3 -0
- package/dist/core/target-subtree.js +12 -2
- package/dist/project/anchors/application.js +4 -4
- package/dist/project/download.d.ts +41 -0
- package/dist/project/download.js +162 -0
- package/dist/project/install.d.ts +2 -41
- package/dist/project/install.js +1 -117
- package/dist/project/tar-gz.d.ts +5 -7
- package/dist/project/tar-gz.js +159 -63
- package/package.json +2 -2
package/bin/moeicons.js
CHANGED
|
@@ -9,6 +9,14 @@ if (!checked.ok) {
|
|
|
9
9
|
process.exit(1);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Plain help/version do not need the generation, extraction or MCP modules.
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
if (args.length === 1 && ["--help", "-h", "help"].includes(args[0])) {
|
|
15
|
+
const { HELP_TEXT } = await import("../dist/commands/parser.js");
|
|
16
|
+
process.stdout.write(HELP_TEXT);
|
|
17
|
+
} else if (args.length === 1 && ["--version", "-v"].includes(args[0])) {
|
|
18
|
+
process.stdout.write(require("../package.json").version + "\n");
|
|
19
|
+
} else {
|
|
12
20
|
const { main } = await import("../dist/cli.js");
|
|
13
21
|
|
|
14
22
|
const runtime = {
|
|
@@ -29,3 +37,5 @@ main(process.argv.slice(2), runtime).then(
|
|
|
29
37
|
process.exitCode = 5;
|
|
30
38
|
},
|
|
31
39
|
);
|
|
40
|
+
|
|
41
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"catalogVersion": "0.0.
|
|
4
|
-
"sourceVersion": "0.0.
|
|
5
|
-
"sourceCommit": "
|
|
6
|
-
"generatorCommit": "
|
|
3
|
+
"catalogVersion": "0.0.19",
|
|
4
|
+
"sourceVersion": "0.0.19",
|
|
5
|
+
"sourceCommit": "678b0ceec3ff1f5c925418365cecef6c083a26a0",
|
|
6
|
+
"generatorCommit": "07de3eeac3f48438e31ff907ed47ab87d8ddf909",
|
|
7
7
|
"manifestSchemaVersion": 2,
|
|
8
8
|
"styleGroups": [
|
|
9
9
|
{
|
|
@@ -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,
|
|
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 =
|
|
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,
|
package/dist/core/generate.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/generate.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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,
|
package/dist/core/install.js
CHANGED
|
@@ -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,
|
|
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
|
-
:
|
|
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 {
|
|
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,
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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 {
|
|
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 =
|
|
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,
|
package/dist/core/pro-install.js
CHANGED
|
@@ -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,
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
96
|
-
|
|
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";
|
package/dist/project/install.js
CHANGED
|
@@ -630,120 +630,4 @@ export function createFileIfAbsent(target, content, fs_) {
|
|
|
630
630
|
fs_.rmSync(staging, { force: true });
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
|
-
|
|
634
|
-
* Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
|
|
635
|
-
* timeout, byte limit. Abort and temporary-file cleanup are handled by the
|
|
636
|
-
* caller via the injected fetch/signal.
|
|
637
|
-
*
|
|
638
|
-
* Redirect policy: unlike the API descriptor handshake (which uses
|
|
639
|
-
* `redirect: "error"`), payload downloads intentionally follow a bounded number
|
|
640
|
-
* of redirects because code/metadata archives are served from CDNs that 3xx to
|
|
641
|
-
* a signed object host; the destination host is re-checked against the
|
|
642
|
-
* allowlist on every hop.
|
|
643
|
-
*/
|
|
644
|
-
export async function downloadArtifact(url, limits, deps = {}) {
|
|
645
|
-
const parsed = new URL(url);
|
|
646
|
-
const loopbackHttp = limits.allowHttpLoopback === true && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1");
|
|
647
|
-
if (parsed.protocol !== "https:" && !loopbackHttp) {
|
|
648
|
-
return { ok: false, code: "NON_HTTPS", message: "artifact URLs must use https" };
|
|
649
|
-
}
|
|
650
|
-
if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.host)) {
|
|
651
|
-
return { ok: false, code: "HOST_NOT_ALLOWED", message: `host ${parsed.host} not in allowlist` };
|
|
652
|
-
}
|
|
653
|
-
const fetchFn = deps.fetchFn ?? globalThis.fetch;
|
|
654
|
-
const controller = new AbortController();
|
|
655
|
-
const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
|
|
656
|
-
const abortHandler = () => controller.abort();
|
|
657
|
-
deps.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
658
|
-
try {
|
|
659
|
-
let currentUrl = url;
|
|
660
|
-
let redirects = 0;
|
|
661
|
-
let response;
|
|
662
|
-
for (;;) {
|
|
663
|
-
response = await fetchFn(currentUrl, {
|
|
664
|
-
method: "GET",
|
|
665
|
-
redirect: "manual",
|
|
666
|
-
signal: controller.signal,
|
|
667
|
-
...(limits.userAgent ? { headers: { "user-agent": limits.userAgent } } : {}),
|
|
668
|
-
});
|
|
669
|
-
if (response.status >= 300 && response.status < 400) {
|
|
670
|
-
redirects += 1;
|
|
671
|
-
if (redirects > limits.maxRedirects) {
|
|
672
|
-
return { ok: false, code: "TOO_MANY_REDIRECTS", message: `exceeded ${limits.maxRedirects} redirects` };
|
|
673
|
-
}
|
|
674
|
-
const location = response.headers.get("location");
|
|
675
|
-
if (!location) {
|
|
676
|
-
return { ok: false, code: "REDIRECT_NO_LOCATION", message: "redirect without location header" };
|
|
677
|
-
}
|
|
678
|
-
currentUrl = new URL(location, currentUrl).toString();
|
|
679
|
-
const next = new URL(currentUrl);
|
|
680
|
-
const nextLoopback = limits.allowHttpLoopback === true && next.protocol === "http:" && (next.hostname === "127.0.0.1" || next.hostname === "localhost" || next.hostname === "::1");
|
|
681
|
-
if (next.protocol !== "https:" && !nextLoopback) {
|
|
682
|
-
return { ok: false, code: "NON_HTTPS", message: "redirect to non-https url" };
|
|
683
|
-
}
|
|
684
|
-
if (limits.allowedHosts && !limits.allowedHosts.includes(next.host)) {
|
|
685
|
-
return { ok: false, code: "HOST_NOT_ALLOWED", message: `redirect host ${next.host} not in allowlist` };
|
|
686
|
-
}
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
break;
|
|
690
|
-
}
|
|
691
|
-
if (!response || response.status >= 400) {
|
|
692
|
-
return { ok: false, code: "HTTP_ERROR", message: `request failed with ${response.status ?? "unknown"}` };
|
|
693
|
-
}
|
|
694
|
-
const rawLength = response.headers.get("content-length");
|
|
695
|
-
const contentLength = rawLength !== null && /^\d+$/.test(rawLength) ? Number(rawLength) : undefined;
|
|
696
|
-
if (contentLength !== undefined && contentLength > limits.maxBytes) {
|
|
697
|
-
return { ok: false, code: "TOO_LARGE", message: `content-length ${contentLength} exceeds limit` };
|
|
698
|
-
}
|
|
699
|
-
const chunks = [];
|
|
700
|
-
let downloadedBytes = 0;
|
|
701
|
-
if (response.body) {
|
|
702
|
-
const reader = response.body.getReader();
|
|
703
|
-
for (;;) {
|
|
704
|
-
const chunk = await reader.read();
|
|
705
|
-
if (chunk.done)
|
|
706
|
-
break;
|
|
707
|
-
const value = chunk.value;
|
|
708
|
-
downloadedBytes += value.byteLength;
|
|
709
|
-
if (downloadedBytes > limits.maxBytes) {
|
|
710
|
-
await reader.cancel();
|
|
711
|
-
return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
|
|
712
|
-
}
|
|
713
|
-
chunks.push(value);
|
|
714
|
-
limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
const buffer = await response.arrayBuffer();
|
|
719
|
-
downloadedBytes = buffer.byteLength;
|
|
720
|
-
if (downloadedBytes > limits.maxBytes)
|
|
721
|
-
return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
|
|
722
|
-
chunks.push(new Uint8Array(buffer));
|
|
723
|
-
limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
|
|
724
|
-
}
|
|
725
|
-
const bytes = new Uint8Array(downloadedBytes);
|
|
726
|
-
let offset = 0;
|
|
727
|
-
for (const chunk of chunks) {
|
|
728
|
-
bytes.set(chunk, offset);
|
|
729
|
-
offset += chunk.byteLength;
|
|
730
|
-
}
|
|
731
|
-
return { ok: true, bytes, finalUrl: currentUrl };
|
|
732
|
-
}
|
|
733
|
-
catch (error) {
|
|
734
|
-
return {
|
|
735
|
-
ok: false,
|
|
736
|
-
code: "NETWORK_ERROR",
|
|
737
|
-
message: error instanceof Error ? error.message : String(error),
|
|
738
|
-
};
|
|
739
|
-
}
|
|
740
|
-
finally {
|
|
741
|
-
clearTimeout(timer);
|
|
742
|
-
deps.signal?.removeEventListener("abort", abortHandler);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
/** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
|
|
746
|
-
export function verifyArtifact(bytes, expectedSha256) {
|
|
747
|
-
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
748
|
-
return { ok: actual.toLowerCase() === expectedSha256.toLowerCase(), actual };
|
|
749
|
-
}
|
|
633
|
+
export { downloadArtifact, verifyArtifact } from "./download.js";
|
package/dist/project/tar-gz.d.ts
CHANGED
|
@@ -15,15 +15,13 @@ export type ExtractedTarFiles = {
|
|
|
15
15
|
readonly files: Record<string, Uint8Array>;
|
|
16
16
|
readonly errors: string[];
|
|
17
17
|
};
|
|
18
|
-
/**
|
|
19
|
-
* Gunzip + unpack tar. Rejects absolute paths, `..`, symlinks and oversized
|
|
20
|
-
* archives. Path safety uses POSIX `/` separators (the tar format mandates `/`);
|
|
21
|
-
* a Windows-style separator would appear literally inside a name and never be
|
|
22
|
-
* treated as a path separator, so `..\evil` is inert (no traversal), which is
|
|
23
|
-
* the documented known assumption.
|
|
24
|
-
*/
|
|
25
18
|
export declare function extractTarGz(bytes: Uint8Array, limits: {
|
|
26
19
|
maxEntries: number;
|
|
27
20
|
maxExpandedBytes: number;
|
|
28
21
|
}): ExtractedTarFiles;
|
|
22
|
+
/** Production path: yield between chunks; never materialise the complete tar. */
|
|
23
|
+
export declare function extractTarGzAsync(bytes: Uint8Array, limits: {
|
|
24
|
+
maxEntries: number;
|
|
25
|
+
maxExpandedBytes: number;
|
|
26
|
+
}, signal?: AbortSignal): Promise<ExtractedTarFiles>;
|
|
29
27
|
export declare function decodeUtf8(bytes: Uint8Array): string;
|
package/dist/project/tar-gz.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { gzipSync, gunzipSync } from "node:zlib";
|
|
1
|
+
import { gzipSync, gunzipSync, createGunzip } from "node:zlib";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
2
3
|
const BLOCK = 512;
|
|
3
4
|
/**
|
|
4
5
|
* Combined release archives contain all four target trees. The current Pro
|
|
@@ -60,77 +61,172 @@ export function createTarGz(files) {
|
|
|
60
61
|
return gzipSync(Buffer.concat(parts), { level: 9 });
|
|
61
62
|
}
|
|
62
63
|
function readOctal(buf, offset, length) {
|
|
63
|
-
const raw = buf.subarray(offset, offset + length).toString("
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const raw = buf.subarray(offset, offset + length).toString("ascii").replace(/\0.*$/, "").trim();
|
|
65
|
+
if (!/^[0-7]+$/.test(raw))
|
|
66
|
+
throw new Error("invalid tar numeric field");
|
|
67
|
+
const value = Number.parseInt(raw, 8);
|
|
68
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
69
|
+
throw new Error("invalid tar size");
|
|
70
|
+
return value;
|
|
67
71
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
/** One incremental parser used by both fixture and production extraction. */
|
|
73
|
+
class TarReader {
|
|
74
|
+
limits;
|
|
75
|
+
files = {};
|
|
76
|
+
errors = [];
|
|
77
|
+
header = Buffer.alloc(BLOCK);
|
|
78
|
+
headerBytes = 0;
|
|
79
|
+
body;
|
|
80
|
+
bodyBytes = 0;
|
|
81
|
+
padding = 0;
|
|
82
|
+
name = "";
|
|
83
|
+
longName;
|
|
84
|
+
type = 0;
|
|
85
|
+
total = 0;
|
|
86
|
+
entries = 0;
|
|
87
|
+
zeroBlocks = 0;
|
|
88
|
+
failed = false;
|
|
89
|
+
constructor(limits) {
|
|
90
|
+
this.limits = limits;
|
|
83
91
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
fail(message) { this.errors.push(message); this.failed = true; }
|
|
93
|
+
feed(bytes) {
|
|
94
|
+
if (this.failed)
|
|
95
|
+
return;
|
|
96
|
+
this.total += bytes.length;
|
|
97
|
+
if (this.total > this.limits.maxExpandedBytes)
|
|
98
|
+
return this.fail("expanded size exceeds limit");
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset < bytes.length && !this.failed) {
|
|
101
|
+
if (this.body) {
|
|
102
|
+
const count = Math.min(this.body.length - this.bodyBytes, bytes.length - offset);
|
|
103
|
+
this.body.set(bytes.subarray(offset, offset + count), this.bodyBytes);
|
|
104
|
+
offset += count;
|
|
105
|
+
this.bodyBytes += count;
|
|
106
|
+
if (this.bodyBytes !== this.body.length)
|
|
107
|
+
continue;
|
|
108
|
+
this.finishBody();
|
|
109
|
+
}
|
|
110
|
+
else if (this.padding) {
|
|
111
|
+
const count = Math.min(this.padding, bytes.length - offset);
|
|
112
|
+
offset += count;
|
|
113
|
+
this.padding -= count;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const count = Math.min(BLOCK - this.headerBytes, bytes.length - offset);
|
|
117
|
+
this.header.set(bytes.subarray(offset, offset + count), this.headerBytes);
|
|
118
|
+
this.headerBytes += count;
|
|
119
|
+
offset += count;
|
|
120
|
+
if (this.headerBytes === BLOCK) {
|
|
121
|
+
this.headerBytes = 0;
|
|
122
|
+
this.beginBody();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
88
126
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
offset += BLOCK;
|
|
95
|
-
if (header.every((byte) => byte === 0))
|
|
96
|
-
break;
|
|
97
|
-
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "").replace(/^\.\//, "");
|
|
98
|
-
const size = readOctal(header, 124, 12);
|
|
99
|
-
const type = header[156];
|
|
100
|
-
const padded = Math.ceil(size / BLOCK) * BLOCK;
|
|
101
|
-
const content = data.subarray(offset, offset + size);
|
|
102
|
-
offset += padded;
|
|
103
|
-
if (type === 0x31 || type === 0x32) {
|
|
104
|
-
// Hard-link (typeflag '1') and symlink (typeflag '2') entries are
|
|
105
|
-
// rejected with an explicit error, never silently skipped.
|
|
106
|
-
errors.push(`link entries are not allowed: ${name || "<unnamed>"}`);
|
|
107
|
-
continue;
|
|
127
|
+
beginBody() {
|
|
128
|
+
const header = this.header;
|
|
129
|
+
if (header.every(byte => byte === 0)) {
|
|
130
|
+
this.zeroBlocks++;
|
|
131
|
+
return;
|
|
108
132
|
}
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
if (this.zeroBlocks)
|
|
134
|
+
return this.fail("tar data after end marker");
|
|
135
|
+
try {
|
|
136
|
+
const expected = readOctal(header, 148, 8);
|
|
137
|
+
const copy = Buffer.from(header);
|
|
138
|
+
copy.fill(0x20, 148, 156);
|
|
139
|
+
if (checksumHeader(copy) !== expected)
|
|
140
|
+
return this.fail("invalid tar header checksum");
|
|
141
|
+
const size = readOctal(header, 124, 12);
|
|
142
|
+
if (size > this.limits.maxExpandedBytes)
|
|
143
|
+
return this.fail("expanded size exceeds limit");
|
|
144
|
+
this.entries++;
|
|
145
|
+
if (this.entries > this.limits.maxEntries)
|
|
146
|
+
return this.fail(`too many entries (> ${this.limits.maxEntries})`);
|
|
147
|
+
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
148
|
+
const prefix = header.subarray(257, 263).toString("ascii") === "ustar\0"
|
|
149
|
+
? header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "") : "";
|
|
150
|
+
this.name = this.longName ?? (prefix ? `${prefix}/${name}` : name);
|
|
151
|
+
this.longName = undefined;
|
|
152
|
+
this.name = this.name.replace(/^\.\//, "");
|
|
153
|
+
this.type = header[156] ?? 0;
|
|
154
|
+
this.padding = (BLOCK - size % BLOCK) % BLOCK;
|
|
155
|
+
this.bodyBytes = 0;
|
|
156
|
+
this.body = Buffer.alloc(size);
|
|
157
|
+
if (size === 0)
|
|
158
|
+
this.finishBody();
|
|
117
159
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
continue;
|
|
160
|
+
catch (error) {
|
|
161
|
+
this.fail(error instanceof Error ? error.message : "invalid tar header");
|
|
121
162
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
163
|
+
}
|
|
164
|
+
finishBody() {
|
|
165
|
+
const body = this.body;
|
|
166
|
+
this.body = undefined;
|
|
167
|
+
if (this.type === 0x4c) { // GNU long names, emitted by the production packer.
|
|
168
|
+
this.longName = body.toString("utf8").replace(/\0.*$/, "");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (this.type === 0x31 || this.type === 0x32) {
|
|
172
|
+
this.errors.push(`link entries are not allowed: ${this.name}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (this.type === 0x35)
|
|
176
|
+
return;
|
|
177
|
+
if (this.type !== 0 && this.type !== 0x30)
|
|
178
|
+
return this.fail("unsupported tar entry type");
|
|
179
|
+
if (!this.name || this.name.startsWith("/") || this.name.split("/").includes("..")) {
|
|
180
|
+
this.errors.push(`unsafe path "${this.name}"`);
|
|
181
|
+
return;
|
|
125
182
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
break;
|
|
183
|
+
if (this.name in this.files) {
|
|
184
|
+
this.errors.push(`duplicate entry "${this.name}"`);
|
|
185
|
+
return;
|
|
130
186
|
}
|
|
131
|
-
files[name] =
|
|
187
|
+
this.files[this.name] = body;
|
|
188
|
+
}
|
|
189
|
+
result() {
|
|
190
|
+
if (!this.failed && (this.body || this.padding || this.headerBytes || this.zeroBlocks < 2 || this.longName))
|
|
191
|
+
this.errors.push("truncated tar archive");
|
|
192
|
+
return { files: this.files, errors: this.errors };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export function extractTarGz(bytes, limits) {
|
|
196
|
+
const reader = new TarReader(limits);
|
|
197
|
+
try {
|
|
198
|
+
reader.feed(gunzipSync(bytes, { maxOutputLength: limits.maxExpandedBytes }));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
return { files: {}, errors: [isOutputLimitError(error) ? "expanded size exceeds limit" : "invalid gzip"] };
|
|
202
|
+
}
|
|
203
|
+
return reader.result();
|
|
204
|
+
}
|
|
205
|
+
/** Production path: yield between chunks; never materialise the complete tar. */
|
|
206
|
+
export async function extractTarGzAsync(bytes, limits, signal) {
|
|
207
|
+
const reader = new TarReader(limits);
|
|
208
|
+
const stream = Readable.from([bytes]).pipe(createGunzip());
|
|
209
|
+
const abort = () => stream.destroy(new Error("archive extraction cancelled"));
|
|
210
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
211
|
+
if (signal?.aborted)
|
|
212
|
+
abort();
|
|
213
|
+
try {
|
|
214
|
+
for await (const chunk of stream) {
|
|
215
|
+
reader.feed(chunk);
|
|
216
|
+
if (reader.errors.length) {
|
|
217
|
+
stream.destroy();
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return reader.result();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return { files: {}, errors: [signal?.aborted ? "archive extraction cancelled" : "invalid gzip"] };
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
signal?.removeEventListener("abort", abort);
|
|
228
|
+
stream.destroy();
|
|
132
229
|
}
|
|
133
|
-
return { files, errors };
|
|
134
230
|
}
|
|
135
231
|
export function decodeUtf8(bytes) {
|
|
136
232
|
return Buffer.from(bytes).toString("utf8");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moewolf/moe-icons-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Moeicons CLI: install React, Vue, Vanilla, or Assets icon targets, manage releases and login, and expose an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
@@ -74,5 +74,5 @@
|
|
|
74
74
|
"fflate": "^0.8.2",
|
|
75
75
|
"jsonc-parser": "^3.3.1"
|
|
76
76
|
},
|
|
77
|
-
"payloadHash": "
|
|
77
|
+
"payloadHash": "67b1f87554a7fd3a69eb15721d5e8746948be5eac4ac4da15786f58b790adb6e"
|
|
78
78
|
}
|