@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.
- package/README.md +14 -9
- package/bin/moeicons.js +10 -0
- package/dist/catalog/catalog.json +12282 -622
- 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/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.4",
|
|
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": "abc4b8255136c07072b0c1b34bc2c960407997d8ce783515cbd51c439faaa820"
|
|
78
78
|
}
|