@moewolf/moe-icons-cli 0.0.11 → 0.0.14
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 +27 -19
- package/dist/cli.js +124 -146
- package/dist/config-package/generated/SOURCE.json +3 -3
- package/dist/config-package/generated/moeicons-config.schema.json +1 -1
- package/dist/core/bootstrap.js +5 -3
- package/dist/core/candidate-version-pointer.d.ts +13 -0
- package/dist/core/candidate-version-pointer.js +37 -0
- package/dist/core/config-package-plan.d.ts +25 -0
- package/dist/core/config-package-plan.js +156 -0
- package/dist/core/doctor.js +4 -0
- package/dist/core/free-download.js +34 -4
- package/dist/core/free-small-package-download.d.ts +67 -0
- package/dist/core/free-small-package-download.js +344 -0
- package/dist/core/generate.js +49 -33
- package/dist/core/install.d.ts +8 -2
- package/dist/core/install.js +70 -0
- package/dist/core/library-update.d.ts +6 -1
- package/dist/core/library-update.js +54 -0
- package/dist/core/manage.d.ts +4 -0
- package/dist/core/manage.js +4 -0
- package/dist/core/metadata-sync.js +119 -1
- package/dist/core/pro-download.js +19 -2
- package/dist/core/pro-install.d.ts +10 -4
- package/dist/core/pro-install.js +47 -0
- package/dist/core/pro-resources.d.ts +23 -0
- package/dist/core/pro-resources.js +51 -0
- package/dist/core/pro-selected-download.js +12 -2
- package/dist/core/pro-small-package-download.d.ts +61 -0
- package/dist/core/pro-small-package-download.js +301 -0
- package/dist/core/release-descriptor.js +6 -2
- package/dist/core/small-pack/PIN.json +11 -0
- package/dist/core/small-pack/candidate-set-v1.schema.json +187 -0
- package/dist/core/small-pack/candidate-set.cjs +286 -0
- package/dist/core/small-pack/candidate-set.d.cts +12 -0
- package/dist/core/small-pack/tar-read.cjs +220 -0
- package/dist/core/small-pack/tar-read.d.cts +2 -0
- package/dist/core/small-package-install.d.ts +40 -0
- package/dist/core/small-package-install.js +252 -0
- package/dist/core/small-package-selection.d.ts +27 -0
- package/dist/core/small-package-selection.js +114 -0
- package/dist/core/update-policy.d.ts +1 -0
- package/dist/core/update-policy.js +10 -3
- package/dist/mcp/services.js +1 -1
- package/dist/project/config.js +1 -1
- package/dist/project/install-metadata.d.ts +2 -1
- package/dist/project/install-metadata.js +6 -3
- package/dist/project/install.d.ts +11 -2
- package/dist/project/install.js +104 -12
- package/dist/project/package-install-metadata.d.ts +31 -0
- package/dist/project/package-install-metadata.js +99 -0
- package/dist/project/tar-gz.d.ts +4 -8
- package/dist/project/tar-gz.js +6 -10
- package/package.json +11 -11
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PATCH-24-C: strict structured tar reader.
|
|
5
|
+
*
|
|
6
|
+
* Validates header checksum, strict octal/base-256 size, payload truncation and
|
|
7
|
+
* padding, the end-of-archive marker with no non-zero trailing bytes, PAX record
|
|
8
|
+
* lengths/scopes and GNU long names. Returns entries with the raw path plus the
|
|
9
|
+
* file bytes so callers never have to hand the same archive to a second parser
|
|
10
|
+
* (e.g. system tar) that might interpret it differently.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BLOCK = 512;
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 200000;
|
|
15
|
+
// CI FLOW RULE: parse one bounded resource package per invocation, not a
|
|
16
|
+
// concatenated tier archive.
|
|
17
|
+
const DEFAULT_MAX_EXPANDED_BYTES = 128 * 1024 * 1024;
|
|
18
|
+
const MAX_METADATA_BYTES = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function readString(buffer, offset, length) {
|
|
21
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
22
|
+
const end = slice.indexOf(0);
|
|
23
|
+
return (end >= 0 ? slice.subarray(0, end) : slice).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function roundUp(size) {
|
|
27
|
+
return Math.ceil(size / BLOCK) * BLOCK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isZeroBlock(buffer, offset) {
|
|
31
|
+
for (let index = offset; index < offset + BLOCK; index += 1) {
|
|
32
|
+
if (buffer[index] !== 0) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseOctal(field, label) {
|
|
38
|
+
const text = field.toString('latin1').replace(/\0.*$/, '').trim();
|
|
39
|
+
if (text === '') return 0;
|
|
40
|
+
if (!/^[0-7]+$/.test(text)) throw new Error(`tar ${label} is not valid octal: ${JSON.stringify(text)}`);
|
|
41
|
+
const value = Number.parseInt(text, 8);
|
|
42
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`tar ${label} is out of range`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** GNU base-256 encoding when the high bit of the first byte is set. */
|
|
47
|
+
function parseSize(field) {
|
|
48
|
+
if (field[0] & 0x80) {
|
|
49
|
+
let value = 0n;
|
|
50
|
+
for (const byte of field) value = (value << 8n) | BigInt(byte & 0x7f);
|
|
51
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('tar size overflows safe integer');
|
|
52
|
+
return Number(value);
|
|
53
|
+
}
|
|
54
|
+
return parseOctal(field, 'size');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseChecksum(field) {
|
|
58
|
+
const text = field.toString('latin1').replace(/\0.*$/, '').trim();
|
|
59
|
+
if (!/^[0-7]+$/.test(text)) throw new Error(`tar checksum is not valid octal: ${JSON.stringify(text)}`);
|
|
60
|
+
return Number.parseInt(text, 8);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computeChecksums(header) {
|
|
64
|
+
let unsigned = 0;
|
|
65
|
+
let signed = 0;
|
|
66
|
+
for (let index = 0; index < BLOCK; index += 1) {
|
|
67
|
+
const byte = index >= 148 && index < 156 ? 0x20 : header[index];
|
|
68
|
+
unsigned += byte;
|
|
69
|
+
signed += byte < 0x80 ? byte : byte - 0x100;
|
|
70
|
+
}
|
|
71
|
+
return { unsigned, signed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function verifyChecksum(header, offset) {
|
|
75
|
+
const stored = parseChecksum(header.subarray(148, 156));
|
|
76
|
+
const { unsigned, signed } = computeChecksums(header);
|
|
77
|
+
if (stored !== unsigned && stored !== signed) {
|
|
78
|
+
throw new Error(`tar checksum mismatch at block ${offset / BLOCK}: stored ${stored}, computed ${unsigned}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parsePax(data) {
|
|
83
|
+
const record = {};
|
|
84
|
+
let cursor = 0;
|
|
85
|
+
while (cursor < data.length) {
|
|
86
|
+
const space = data.indexOf(0x20, cursor);
|
|
87
|
+
if (space < 0) throw new Error('malformed pax record: missing length separator');
|
|
88
|
+
const lengthText = data.subarray(cursor, space).toString('latin1');
|
|
89
|
+
if (!/^\d+$/.test(lengthText)) throw new Error('malformed pax record: non-numeric length');
|
|
90
|
+
const length = Number.parseInt(lengthText, 10);
|
|
91
|
+
if (length <= 0 || cursor + length > data.length) throw new Error('malformed pax record: length overruns payload');
|
|
92
|
+
const raw = data.subarray(space + 1, cursor + length);
|
|
93
|
+
if (raw.length === 0 || raw[raw.length - 1] !== 0x0a) throw new Error('malformed pax record: missing newline');
|
|
94
|
+
// Lengths are UTF-8 byte counts; decode only the value bytes.
|
|
95
|
+
const body = raw.subarray(0, raw.length - 1).toString('utf8');
|
|
96
|
+
const equals = body.indexOf('=');
|
|
97
|
+
if (equals < 0) throw new Error('malformed pax record: missing "="');
|
|
98
|
+
record[body.slice(0, equals)] = body.slice(equals + 1);
|
|
99
|
+
cursor += length;
|
|
100
|
+
}
|
|
101
|
+
if (cursor !== data.length) throw new Error('malformed pax record: trailing bytes');
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Buffer} buffer full tar archive bytes
|
|
107
|
+
* @param {{maxEntries?:number,maxExpandedBytes?:number}} [options]
|
|
108
|
+
*/
|
|
109
|
+
function readTarEntries(buffer, options = {}) {
|
|
110
|
+
const maxEntries = options.maxEntries || DEFAULT_MAX_ENTRIES;
|
|
111
|
+
const maxExpandedBytes = options.maxExpandedBytes || DEFAULT_MAX_EXPANDED_BYTES;
|
|
112
|
+
const entries = [];
|
|
113
|
+
let offset = 0;
|
|
114
|
+
let pendingLongName = null;
|
|
115
|
+
let pendingPax = null;
|
|
116
|
+
let globalPax = {};
|
|
117
|
+
let expandedBytes = 0;
|
|
118
|
+
let metadataCount = 0;
|
|
119
|
+
let terminated = false;
|
|
120
|
+
|
|
121
|
+
while (offset < buffer.length) {
|
|
122
|
+
if (offset + BLOCK > buffer.length) throw new Error(`tar truncated header at offset ${offset}`);
|
|
123
|
+
if (isZeroBlock(buffer, offset)) {
|
|
124
|
+
// POSIX requires two consecutive 512-byte zero blocks; anything after the
|
|
125
|
+
// two blocks must be zero record padding.
|
|
126
|
+
const second = offset + BLOCK;
|
|
127
|
+
if (second + BLOCK > buffer.length || !isZeroBlock(buffer, second)) {
|
|
128
|
+
throw new Error('tar is missing the two-block end-of-archive marker');
|
|
129
|
+
}
|
|
130
|
+
for (let index = second; index < buffer.length; index += 1) {
|
|
131
|
+
if (buffer[index] !== 0) throw new Error('tar has trailing data after the end-of-archive marker');
|
|
132
|
+
}
|
|
133
|
+
terminated = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const header = buffer.subarray(offset, offset + BLOCK);
|
|
137
|
+
verifyChecksum(header, offset);
|
|
138
|
+
|
|
139
|
+
const name = readString(header, 0, 100);
|
|
140
|
+
const type = String.fromCharCode(header[156]);
|
|
141
|
+
const linkname = readString(header, 157, 100);
|
|
142
|
+
const prefix = readString(header, 345, 155);
|
|
143
|
+
const size = parseSize(header.subarray(124, 136));
|
|
144
|
+
const dataStart = offset + BLOCK;
|
|
145
|
+
const paddedSize = roundUp(size);
|
|
146
|
+
if (dataStart + paddedSize > buffer.length) {
|
|
147
|
+
throw new Error(`tar payload truncated for ${name || '<unnamed>'} (size ${size})`);
|
|
148
|
+
}
|
|
149
|
+
if (size < 0) throw new Error('tar negative size');
|
|
150
|
+
expandedBytes += size;
|
|
151
|
+
if (expandedBytes > maxExpandedBytes) throw new Error('tar expanded size exceeds limit');
|
|
152
|
+
const data = buffer.subarray(dataStart, dataStart + size);
|
|
153
|
+
// Padding bytes must be zero.
|
|
154
|
+
for (let index = dataStart + size; index < dataStart + paddedSize; index += 1) {
|
|
155
|
+
if (buffer[index] !== 0) throw new Error(`tar non-zero padding for ${name || '<unnamed>'}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let filePath = prefix ? `${prefix}/${name}` : name;
|
|
159
|
+
if (pendingLongName !== null) filePath = pendingLongName;
|
|
160
|
+
const pax = pendingPax ? { ...globalPax, ...pendingPax } : globalPax;
|
|
161
|
+
if (pax.path) filePath = pax.path;
|
|
162
|
+
const entryLink = pax.linkpath || linkname;
|
|
163
|
+
|
|
164
|
+
const isMetadata = type === 'L' || type === 'x' || type === 'g';
|
|
165
|
+
if (isMetadata) {
|
|
166
|
+
// Metadata headers count toward the entry budget and are size-capped.
|
|
167
|
+
metadataCount += 1;
|
|
168
|
+
if (entries.length + metadataCount > maxEntries) throw new Error('tar entry count exceeds limit');
|
|
169
|
+
if (size > MAX_METADATA_BYTES) throw new Error('tar metadata entry exceeds size limit');
|
|
170
|
+
}
|
|
171
|
+
if (type === 'L') {
|
|
172
|
+
pendingLongName = data.toString('utf8').replace(/\0+$/, '');
|
|
173
|
+
pendingPax = null;
|
|
174
|
+
offset = dataStart + paddedSize;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (type === 'x' || type === 'g') {
|
|
178
|
+
const parsed = parsePax(data);
|
|
179
|
+
if (type === 'x') pendingPax = parsed;
|
|
180
|
+
else globalPax = parsed;
|
|
181
|
+
offset = dataStart + paddedSize;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
pendingLongName = null;
|
|
185
|
+
pendingPax = null;
|
|
186
|
+
|
|
187
|
+
if (entries.length >= maxEntries) throw new Error('tar entry count exceeds limit');
|
|
188
|
+
|
|
189
|
+
let kind;
|
|
190
|
+
if (type === '5') kind = 'dir';
|
|
191
|
+
else if (type === '0' || type === '\0' || type === '\u0000') kind = 'file';
|
|
192
|
+
else if (type === '2') kind = 'symlink';
|
|
193
|
+
else if (type === '1') kind = 'hardlink';
|
|
194
|
+
else if (type === '6') kind = 'fifo';
|
|
195
|
+
else kind = `other:${type}`;
|
|
196
|
+
|
|
197
|
+
if (!isMetadata && (!filePath || filePath.length === 0)) throw new Error('tar entry has an empty path');
|
|
198
|
+
|
|
199
|
+
entries.push({
|
|
200
|
+
path: kind === 'dir' ? filePath.replace(/\/$/, '') : filePath,
|
|
201
|
+
type: kind,
|
|
202
|
+
linkname: entryLink || null,
|
|
203
|
+
size,
|
|
204
|
+
data: kind === 'file' ? data : null,
|
|
205
|
+
});
|
|
206
|
+
offset = dataStart + paddedSize;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!terminated) throw new Error('tar is missing the end-of-archive marker');
|
|
210
|
+
return entries;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
BLOCK,
|
|
215
|
+
DEFAULT_MAX_ENTRIES,
|
|
216
|
+
DEFAULT_MAX_EXPANDED_BYTES,
|
|
217
|
+
readTarEntries,
|
|
218
|
+
parsePax,
|
|
219
|
+
parseOctal,
|
|
220
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type TransactionalFs } from "../project/install.js";
|
|
2
|
+
import { type MoeiconsConfigFile } from "../project/config.js";
|
|
3
|
+
import type { Target } from "../commands/parser.js";
|
|
4
|
+
export interface SmallPackageInstallResult {
|
|
5
|
+
readonly artifactVersion: string;
|
|
6
|
+
readonly candidateRootSha256: string;
|
|
7
|
+
readonly packageSetSha256: string;
|
|
8
|
+
readonly catalogSha256: string;
|
|
9
|
+
readonly metadataSha256: string;
|
|
10
|
+
readonly artifactBytes: number;
|
|
11
|
+
readonly files: readonly string[];
|
|
12
|
+
readonly packageCount: number;
|
|
13
|
+
readonly planItems: number;
|
|
14
|
+
readonly selectedFiles: number;
|
|
15
|
+
readonly networkBytes: number;
|
|
16
|
+
readonly cacheHit: boolean;
|
|
17
|
+
readonly cacheHits: number;
|
|
18
|
+
readonly metadataCacheHits: number;
|
|
19
|
+
readonly transferRetryCount: number;
|
|
20
|
+
readonly target: Target;
|
|
21
|
+
readonly targetSha256: string;
|
|
22
|
+
readonly targetFileCount: number;
|
|
23
|
+
readonly targetByteCount: number;
|
|
24
|
+
readonly fallbacks: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function runSmallPackageInstall(options: {
|
|
27
|
+
readonly projectRoot: string;
|
|
28
|
+
readonly fullVersion?: string;
|
|
29
|
+
readonly expectedPackageSetSha256?: string;
|
|
30
|
+
readonly proAccessToken?: string;
|
|
31
|
+
readonly apiBaseUrl?: string;
|
|
32
|
+
readonly config: MoeiconsConfigFile;
|
|
33
|
+
readonly plannedConfigDocument: string;
|
|
34
|
+
readonly fs: TransactionalFs;
|
|
35
|
+
readonly cacheDir: string;
|
|
36
|
+
readonly now: () => Date;
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
38
|
+
readonly fetchFn?: typeof fetch;
|
|
39
|
+
readonly fixtureOrigin?: string;
|
|
40
|
+
}): Promise<SmallPackageInstallResult>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, posix } from "node:path";
|
|
4
|
+
import { canonicalBytes, sha256 } from "./small-pack/candidate-set.cjs";
|
|
5
|
+
import { downloadFreeStablePackages, downloadFreeVersionPackages } from "./free-small-package-download.js";
|
|
6
|
+
import { downloadProStablePackages, downloadProVersionPackages } from "./pro-small-package-download.js";
|
|
7
|
+
import { packageTargetFiles, planConfigPackagesFromMetadata } from "./config-package-plan.js";
|
|
8
|
+
import { createInstallPlan, executeInstallPlan, safeManagedPath } from "../project/install.js";
|
|
9
|
+
import { loadConfigDocument } from "../project/config.js";
|
|
10
|
+
import { serializePackageInstallMetadata } from "../project/package-install-metadata.js";
|
|
11
|
+
import { sha256Bytes } from "../project/install-metadata.js";
|
|
12
|
+
import { withProjectLock } from "../project/project-lock.js";
|
|
13
|
+
function targetSubtreeDigest(files) {
|
|
14
|
+
const hash = createHash("sha256");
|
|
15
|
+
let fileCount = 0;
|
|
16
|
+
let byteCount = 0;
|
|
17
|
+
const namesAt = (prefix) => {
|
|
18
|
+
const names = new Set();
|
|
19
|
+
for (const rel of files.keys()) {
|
|
20
|
+
if (!prefix || rel.startsWith(`${prefix}/`)) {
|
|
21
|
+
const rest = prefix ? rel.slice(prefix.length + 1) : rel;
|
|
22
|
+
const slash = rest.indexOf("/");
|
|
23
|
+
names.add(slash === -1 ? rest : rest.slice(0, slash));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
27
|
+
};
|
|
28
|
+
const appendFile = (entry) => {
|
|
29
|
+
const descriptor = openSync(entry.absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
30
|
+
try {
|
|
31
|
+
if (!fstatSync(descriptor).isFile())
|
|
32
|
+
throw new Error("staged target output is not a regular file");
|
|
33
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
34
|
+
let position = 0;
|
|
35
|
+
let count = 1;
|
|
36
|
+
while (count > 0) {
|
|
37
|
+
count = readSync(descriptor, buffer, 0, buffer.length, position);
|
|
38
|
+
if (count === 0)
|
|
39
|
+
break;
|
|
40
|
+
hash.update(buffer.subarray(0, count));
|
|
41
|
+
position += count;
|
|
42
|
+
}
|
|
43
|
+
if (position !== entry.byteSize)
|
|
44
|
+
throw new Error("staged target output changed while calculating install identity");
|
|
45
|
+
byteCount += position;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(descriptor);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const walk = (prefix) => {
|
|
52
|
+
for (const name of namesAt(prefix)) {
|
|
53
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
54
|
+
const entry = files.get(relative);
|
|
55
|
+
if (entry) {
|
|
56
|
+
hash.update(relative).update("\0");
|
|
57
|
+
appendFile(entry);
|
|
58
|
+
hash.update("\0");
|
|
59
|
+
fileCount += 1;
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
walk(relative);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk("");
|
|
66
|
+
return { sha256: hash.digest("hex"), fileCount, byteCount };
|
|
67
|
+
}
|
|
68
|
+
function destination(area, target, path) {
|
|
69
|
+
if (area === "target")
|
|
70
|
+
return `.moeicons/artifact/${target}/${path}`;
|
|
71
|
+
if (area === "assets")
|
|
72
|
+
return `.moeicons/artifact/assets/${path}`;
|
|
73
|
+
return `.moeicons/package-metadata/${path}`;
|
|
74
|
+
}
|
|
75
|
+
export async function runSmallPackageInstall(options) {
|
|
76
|
+
if (options.config.downloadMode === "full")
|
|
77
|
+
throw new Error("package installs require downloadMode=icons; full archive delivery is disabled");
|
|
78
|
+
if (options.config.tier === "pro" && (!options.proAccessToken || options.proAccessToken.length > 8192 || /[\r\n]/.test(options.proAccessToken))) {
|
|
79
|
+
throw new Error("Pro package installs require an authenticated access token");
|
|
80
|
+
}
|
|
81
|
+
if (options.config.icons.length === 0 || Object.keys(options.config.themes).length === 0)
|
|
82
|
+
throw new Error("configure at least one icon and theme before installing selected packages");
|
|
83
|
+
const operationId = randomUUID();
|
|
84
|
+
const stageRoot = `.moeicons/.package-stage-${operationId}`;
|
|
85
|
+
const stageRootPath = safeManagedPath(options.projectRoot, stageRoot).target;
|
|
86
|
+
const controlPath = safeManagedPath(options.projectRoot, ".moeicons").target;
|
|
87
|
+
let packagePlan;
|
|
88
|
+
let candidateRoot;
|
|
89
|
+
let installedFileCount = 0;
|
|
90
|
+
let nextFile = 0;
|
|
91
|
+
const installFiles = Object.create(null);
|
|
92
|
+
const targetFiles = new Map();
|
|
93
|
+
const assetManifestEntries = [];
|
|
94
|
+
const stageFile = (installPath, relativeToTarget, bytes) => {
|
|
95
|
+
if (Object.hasOwn(installFiles, installPath))
|
|
96
|
+
throw new Error(`selected packages produce a duplicate install path: ${installPath}`);
|
|
97
|
+
const stagedRelative = `${stageRoot}/files/${String(nextFile++).padStart(6, "0")}`;
|
|
98
|
+
const stagedPath = safeManagedPath(options.projectRoot, stagedRelative);
|
|
99
|
+
options.fs.mkdirSync(dirname(stagedPath.target), { recursive: true });
|
|
100
|
+
options.fs.writeFileSync(stagedPath.target, bytes);
|
|
101
|
+
const digest = sha256Bytes(bytes);
|
|
102
|
+
const staged = { kind: "staged-file", path: stagedRelative, sha256: digest };
|
|
103
|
+
installFiles[installPath] = staged;
|
|
104
|
+
installedFileCount += 1;
|
|
105
|
+
if (relativeToTarget !== undefined) {
|
|
106
|
+
if (targetFiles.has(relativeToTarget))
|
|
107
|
+
throw new Error(`selected packages produce a duplicate target file: ${relativeToTarget}`);
|
|
108
|
+
targetFiles.set(relativeToTarget, { absolutePath: stagedPath.target, byteSize: bytes.byteLength });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
options.fs.mkdirSync(controlPath, { recursive: true });
|
|
112
|
+
options.fs.mkdirSync(stageRootPath, { recursive: false });
|
|
113
|
+
try {
|
|
114
|
+
const downloadOptions = {
|
|
115
|
+
cacheDir: options.cacheDir,
|
|
116
|
+
...(options.apiBaseUrl ? { apiBaseUrl: options.apiBaseUrl } : {}),
|
|
117
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
118
|
+
...(options.fetchFn ? { fetchFn: options.fetchFn } : {}),
|
|
119
|
+
...(options.fixtureOrigin ? { fixtureOrigin: options.fixtureOrigin } : {}),
|
|
120
|
+
resolveResourceKeys: async (candidate, loadVerifiedPackage) => {
|
|
121
|
+
candidateRoot = candidate;
|
|
122
|
+
packagePlan = await planConfigPackagesFromMetadata(options.config, candidate, loadVerifiedPackage);
|
|
123
|
+
return packagePlan.resourceKeys;
|
|
124
|
+
},
|
|
125
|
+
consume: (row, verifiedFiles) => {
|
|
126
|
+
const mapped = packageTargetFiles(row, verifiedFiles, options.config.target);
|
|
127
|
+
for (const [relative, bytes] of Object.entries(mapped.files)) {
|
|
128
|
+
const installPath = destination(mapped.area, options.config.target, relative);
|
|
129
|
+
if (mapped.area === "assets") {
|
|
130
|
+
assetManifestEntries.push({ path: relative, size: bytes.byteLength, sha256: sha256Bytes(bytes) });
|
|
131
|
+
}
|
|
132
|
+
const targetRelative = mapped.area === "target" || options.config.target === "assets" && mapped.area === "assets"
|
|
133
|
+
? relative
|
|
134
|
+
: undefined;
|
|
135
|
+
stageFile(installPath, targetRelative, bytes);
|
|
136
|
+
}
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const report = options.config.tier === "free"
|
|
141
|
+
? options.fullVersion
|
|
142
|
+
? await downloadFreeVersionPackages({ ...downloadOptions, fullVersion: options.fullVersion, ...(options.expectedPackageSetSha256 ? { expectedPackageSetSha256: options.expectedPackageSetSha256 } : {}) })
|
|
143
|
+
: await downloadFreeStablePackages(downloadOptions)
|
|
144
|
+
: options.fullVersion && options.expectedPackageSetSha256
|
|
145
|
+
? await downloadProVersionPackages({ ...downloadOptions, accessToken: options.proAccessToken, fullVersion: options.fullVersion, expectedPackageSetSha256: options.expectedPackageSetSha256 })
|
|
146
|
+
: options.fullVersion || options.expectedPackageSetSha256
|
|
147
|
+
? (() => { throw new Error("Pro package-set updates require both exact version and package-set SHA-256"); })()
|
|
148
|
+
: await downloadProStablePackages({ ...downloadOptions, accessToken: options.proAccessToken });
|
|
149
|
+
if (!packagePlan || !candidateRoot)
|
|
150
|
+
throw new Error("candidate planner did not produce an install plan");
|
|
151
|
+
if (options.signal?.aborted)
|
|
152
|
+
throw new Error("package install cancelled");
|
|
153
|
+
const catalogJson = `${JSON.stringify(packagePlan.catalog, null, 2)}\n`;
|
|
154
|
+
const catalogSha256 = sha256Bytes(catalogJson);
|
|
155
|
+
installFiles[".moeicons/catalog.json"] = catalogJson;
|
|
156
|
+
if (assetManifestEntries.length > 0) {
|
|
157
|
+
const assets = assetManifestEntries.sort((a, b) => a.path.localeCompare(b.path, "en"));
|
|
158
|
+
const manifestBytes = Buffer.from(`${JSON.stringify({ schemaVersion: 1, assets }, null, 2)}\n`);
|
|
159
|
+
if (options.config.target === "assets")
|
|
160
|
+
stageFile(".moeicons/artifact/assets/manifest.json", "manifest.json", manifestBytes);
|
|
161
|
+
else
|
|
162
|
+
installFiles[".moeicons/artifact/assets/manifest.json"] = manifestBytes;
|
|
163
|
+
}
|
|
164
|
+
installFiles[".moeicons/artifact/package.json"] = '{"private":true,"type":"module","sideEffects":false}\n';
|
|
165
|
+
const outputDir = options.config.outputDir.replace(/\\/g, "/").replace(/\/$/, "");
|
|
166
|
+
installFiles[`${outputDir}/types.ts`] = smallPackageTypesReexport(options.config.target, outputDir);
|
|
167
|
+
installFiles[`${outputDir}/.moeicons-${options.config.tier}.marker`] = `${options.config.tier}\n`;
|
|
168
|
+
const tree = targetSubtreeDigest(targetFiles);
|
|
169
|
+
if (tree.fileCount === 0)
|
|
170
|
+
throw new Error("selected candidate contains no files for the configured target");
|
|
171
|
+
const candidateRootSha256 = sha256(canonicalBytes(candidateRoot.root));
|
|
172
|
+
const packagePins = packagePlan.packages
|
|
173
|
+
.map((row) => ({ resourceKey: row.resourceKey, packageSha256: row.packageSha256, dependencies: [...row.dependencies] }))
|
|
174
|
+
.sort((a, b) => a.resourceKey.localeCompare(b.resourceKey, "en"));
|
|
175
|
+
const metadata = {
|
|
176
|
+
schemaVersion: 2,
|
|
177
|
+
candidate: {
|
|
178
|
+
schemaVersion: 1,
|
|
179
|
+
tier: options.config.tier,
|
|
180
|
+
fullVersion: candidateRoot.root.fullVersion,
|
|
181
|
+
sourceCommit: candidateRoot.root.sourceCommit,
|
|
182
|
+
generatorCommit: candidateRoot.root.generatorCommit,
|
|
183
|
+
packageSetSha256: candidateRoot.root.packageSetSha256,
|
|
184
|
+
candidateRootSha256,
|
|
185
|
+
},
|
|
186
|
+
target: options.config.target,
|
|
187
|
+
resourceKeys: [...packagePlan.resourceKeys].sort((a, b) => a.localeCompare(b, "en")),
|
|
188
|
+
packages: packagePins,
|
|
189
|
+
catalogSha256,
|
|
190
|
+
installedAt: options.now().toISOString(),
|
|
191
|
+
managedFiles: Object.fromEntries(Object.entries(installFiles).map(([path, content]) => [
|
|
192
|
+
path,
|
|
193
|
+
typeof content === "object" && "kind" in content ? content.sha256 : sha256Bytes(content),
|
|
194
|
+
])),
|
|
195
|
+
targetSha256: tree.sha256,
|
|
196
|
+
targetFileCount: tree.fileCount,
|
|
197
|
+
targetByteCount: tree.byteCount,
|
|
198
|
+
generatedOutputDir: outputDir,
|
|
199
|
+
};
|
|
200
|
+
const metadataText = serializePackageInstallMetadata(metadata);
|
|
201
|
+
installFiles[".moeicons/install-metadata.json"] = metadataText;
|
|
202
|
+
const plan = createInstallPlan(options.projectRoot, installFiles);
|
|
203
|
+
await withProjectLock(options.projectRoot, "install", () => {
|
|
204
|
+
if (JSON.stringify(loadConfigDocument(options.projectRoot)) !== options.plannedConfigDocument)
|
|
205
|
+
throw new Error("config changed while preparing package install; retry");
|
|
206
|
+
executeInstallPlan(plan, options.fs);
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
artifactVersion: candidateRoot.root.fullVersion,
|
|
210
|
+
candidateRootSha256,
|
|
211
|
+
packageSetSha256: candidateRoot.root.packageSetSha256,
|
|
212
|
+
catalogSha256,
|
|
213
|
+
metadataSha256: sha256Bytes(metadataText),
|
|
214
|
+
artifactBytes: packagePlan.packages.reduce((sum, row) => sum + row.compressedBytes, 0),
|
|
215
|
+
files: Object.keys(installFiles).sort((a, b) => a.localeCompare(b, "en")),
|
|
216
|
+
packageCount: report.packageCount,
|
|
217
|
+
planItems: plan.items.length,
|
|
218
|
+
selectedFiles: installedFileCount,
|
|
219
|
+
networkBytes: report.networkBytes + report.metadataNetworkBytes,
|
|
220
|
+
cacheHit: report.networkBytes === 0 && report.metadataNetworkBytes === 0,
|
|
221
|
+
cacheHits: report.cacheHits,
|
|
222
|
+
metadataCacheHits: report.metadataCacheHits,
|
|
223
|
+
transferRetryCount: report.transferRetryCount,
|
|
224
|
+
target: options.config.target,
|
|
225
|
+
targetSha256: tree.sha256,
|
|
226
|
+
targetFileCount: tree.fileCount,
|
|
227
|
+
targetByteCount: tree.byteCount,
|
|
228
|
+
fallbacks: packagePlan.fallbacks,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
try {
|
|
233
|
+
if (options.fs.existsSync(stageRootPath))
|
|
234
|
+
options.fs.rmSync(stageRootPath, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
catch (cleanupError) {
|
|
237
|
+
throw new AggregateError([error, cleanupError], `small package install failed; staging cleanup failed at ${stageRootPath}`);
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function smallPackageTypesReexport(target, outputDir) {
|
|
243
|
+
const from = posix.relative(outputDir.replace(/\\/g, "/"), `.moeicons/artifact/${target}/types`);
|
|
244
|
+
const specifier = from.startsWith(".") ? from : `./${from}`;
|
|
245
|
+
if (target === "react")
|
|
246
|
+
return `export type { ReactIconProps } from "${specifier}";\n`;
|
|
247
|
+
if (target === "vue")
|
|
248
|
+
return `export type { VueIconProps } from "${specifier}";\n`;
|
|
249
|
+
if (target === "vanilla")
|
|
250
|
+
return `export type { VanillaIconOptions } from "${specifier}";\n`;
|
|
251
|
+
return "export {};\n";
|
|
252
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type CandidateRoot, type PackageRow, type PageRef } from './small-pack/candidate-set.cjs';
|
|
2
|
+
export interface CandidateIdentity {
|
|
3
|
+
fullVersion: string;
|
|
4
|
+
tier: 'free' | 'pro';
|
|
5
|
+
sourceCommit: string;
|
|
6
|
+
generatorCommit: string;
|
|
7
|
+
packageSetSha256: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SelectedCandidate {
|
|
10
|
+
readonly root: CandidateRoot;
|
|
11
|
+
readonly rows: readonly PackageRow[];
|
|
12
|
+
}
|
|
13
|
+
export declare function readSelectedCandidate(rootBytes: Uint8Array, expected: CandidateIdentity, readPage: (ref: PageRef) => Promise<Uint8Array>): Promise<SelectedCandidate>;
|
|
14
|
+
export declare function planSelectedPackages(candidate: SelectedCandidate, resourceKeys: readonly string[]): readonly PackageRow[];
|
|
15
|
+
export declare function verifySelectedPackage(row: PackageRow, bytes: Uint8Array): Readonly<Record<string, Uint8Array>>;
|
|
16
|
+
export interface SelectedPackageIo {
|
|
17
|
+
readCache(row: PackageRow): Promise<Uint8Array | undefined>;
|
|
18
|
+
writeCache(row: PackageRow, bytes: Uint8Array): Promise<void>;
|
|
19
|
+
download(row: PackageRow): Promise<Uint8Array>;
|
|
20
|
+
consume(row: PackageRow, files: Readonly<Record<string, Uint8Array>>): Promise<void>;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export declare function consumeSelectedPackages(candidate: SelectedCandidate, resourceKeys: readonly string[], io: SelectedPackageIo): Promise<{
|
|
24
|
+
packageCount: number;
|
|
25
|
+
networkBytes: number;
|
|
26
|
+
cacheHits: number;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { gunzipSync } from 'node:zlib';
|
|
2
|
+
import { canonicalBytes, sha256, parseCandidateSet, validatePackagePages, verifyPackagePayload } from './small-pack/candidate-set.cjs';
|
|
3
|
+
import { readTarEntries } from './small-pack/tar-read.cjs';
|
|
4
|
+
function check(ok, message) { if (!ok)
|
|
5
|
+
throw new Error(`small-package selection: ${message}`); }
|
|
6
|
+
export async function readSelectedCandidate(rootBytes, expected, readPage) {
|
|
7
|
+
const root = parseCandidateSet(rootBytes);
|
|
8
|
+
for (const key of ['fullVersion', 'tier', 'sourceCommit', 'generatorCommit', 'packageSetSha256'])
|
|
9
|
+
check(root[key] === expected[key], `candidate ${key} differs from exact pin`);
|
|
10
|
+
check(root.packagePages.reduce((sum, ref) => sum + ref.sizeBytes, 0) <= 16 * 1024 ** 2, 'metadata exceeds 16 MiB budget');
|
|
11
|
+
const pages = new Map();
|
|
12
|
+
for (const ref of root.packagePages)
|
|
13
|
+
pages.set(ref.filename, await readPage({ ...ref }));
|
|
14
|
+
return { root, rows: validatePackagePages(root, pages) };
|
|
15
|
+
}
|
|
16
|
+
export function planSelectedPackages(candidate, resourceKeys) {
|
|
17
|
+
check(resourceKeys.length > 0 && new Set(resourceKeys).size === resourceKeys.length, 'explicit unique resource keys required');
|
|
18
|
+
const keys = new Map(candidate.rows.map(row => [row.resourceKey, row]));
|
|
19
|
+
const digests = new Map(candidate.rows.map(row => [row.packageSha256, row]));
|
|
20
|
+
check(digests.size === candidate.rows.length, 'ambiguous package digest across directory');
|
|
21
|
+
const visited = new Set(), visiting = new Set(), ordered = [];
|
|
22
|
+
const visit = (row) => {
|
|
23
|
+
check(row.tier === candidate.root.tier && row.fullVersion === candidate.root.fullVersion, 'selected row identity mismatch');
|
|
24
|
+
if (visited.has(row.packageSha256))
|
|
25
|
+
return;
|
|
26
|
+
check(!visiting.has(row.packageSha256), 'cyclic package dependency');
|
|
27
|
+
visiting.add(row.packageSha256);
|
|
28
|
+
for (const digest of row.dependencies) {
|
|
29
|
+
const dependency = digests.get(digest);
|
|
30
|
+
check(dependency, 'missing selected dependency');
|
|
31
|
+
visit(dependency);
|
|
32
|
+
}
|
|
33
|
+
visiting.delete(row.packageSha256);
|
|
34
|
+
visited.add(row.packageSha256);
|
|
35
|
+
ordered.push(JSON.parse(canonicalBytes(row).toString('utf8')));
|
|
36
|
+
};
|
|
37
|
+
for (const key of resourceKeys) {
|
|
38
|
+
const row = keys.get(key);
|
|
39
|
+
check(row, `selected resource absent: ${key}`);
|
|
40
|
+
visit(row);
|
|
41
|
+
}
|
|
42
|
+
return ordered;
|
|
43
|
+
}
|
|
44
|
+
function object(value) { check(value && typeof value === 'object' && !Array.isArray(value), 'manifest object required'); return value; }
|
|
45
|
+
export function verifySelectedPackage(row, bytes) {
|
|
46
|
+
check(['svg', 'code', 'shared', 'metadata', 'bitmap'].includes(row.kind), 'unsupported selected package kind');
|
|
47
|
+
verifyPackagePayload(row, bytes);
|
|
48
|
+
const tar = gunzipSync(bytes, { maxOutputLength: row.expandedBytes + (row.fileCount * 4 + 4) * 512 + 10240 });
|
|
49
|
+
const entries = readTarEntries(tar, { maxEntries: row.fileCount * 4 + 4, maxExpandedBytes: row.expandedBytes + 1024 * 1024 });
|
|
50
|
+
const files = Object.create(null);
|
|
51
|
+
const paths = new Set();
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.type === 'dir' && entry.path === '.')
|
|
54
|
+
continue;
|
|
55
|
+
const name = entry.path.startsWith('./') ? entry.path.slice(2) : entry.path;
|
|
56
|
+
check(name.length <= 1024 && !/[\\:]/.test(name) && ![...name].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) && !name.startsWith('/') && name.split('/').every(p => p && p !== '.' && p !== '..'), 'unsafe package path');
|
|
57
|
+
check(!paths.has(name), 'duplicate package path');
|
|
58
|
+
paths.add(name);
|
|
59
|
+
check(entry.type === 'file' || entry.type === 'dir', 'links and special files forbidden');
|
|
60
|
+
if (entry.type === 'file') {
|
|
61
|
+
check(entry.data, 'file bytes missing');
|
|
62
|
+
files[name] = entry.data;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
check(Object.keys(files).length === row.fileCount && Object.values(files).reduce((sum, b) => sum + b.length, 0) === row.expandedBytes, 'package inventory differs from row');
|
|
66
|
+
const bytesManifest = files['manifest.json'];
|
|
67
|
+
check(bytesManifest && bytesManifest.length <= 8 * 1024 ** 2, 'bounded package manifest required');
|
|
68
|
+
const manifest = object(JSON.parse(Buffer.from(bytesManifest).toString('utf8')));
|
|
69
|
+
check(Buffer.from(bytesManifest).equals(canonicalBytes(manifest)) && manifest.schemaVersion === 1, 'canonical package manifest required');
|
|
70
|
+
const scope = ['kind', 'tier', 'styleGroupId', 'target', 'variant'];
|
|
71
|
+
check(Object.keys(manifest).every(k => ['schemaVersion', 'files', ...scope].includes(k)), 'unexpected manifest fields');
|
|
72
|
+
for (const key of scope)
|
|
73
|
+
check(canonicalBytes({ value: manifest[key] }).equals(canonicalBytes({ value: row[key] })), 'package scope differs from row');
|
|
74
|
+
check(Array.isArray(manifest.files) && manifest.files.length === row.fileCount - 1, 'manifest inventory mismatch');
|
|
75
|
+
const seen = new Set(['manifest.json']);
|
|
76
|
+
for (const raw of manifest.files) {
|
|
77
|
+
const item = object(raw);
|
|
78
|
+
check(Object.keys(item).sort().join(',') === 'byteSize,path,sha256' && typeof item.path === 'string' && !seen.has(item.path), 'invalid manifest file');
|
|
79
|
+
seen.add(item.path);
|
|
80
|
+
const data = files[item.path];
|
|
81
|
+
check(data && data.length === item.byteSize && sha256(data) === item.sha256, 'manifest file SHA/size mismatch');
|
|
82
|
+
if (row.kind === 'svg')
|
|
83
|
+
check(/^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/.test(item.path), 'SVG package contains another resource kind');
|
|
84
|
+
if (row.kind === 'code' || row.kind === 'shared')
|
|
85
|
+
check(/\.(js|cjs|mjs|ts|cts|mts|tsx|jsx|json|map|vue)$/.test(item.path), 'code package contains non-code payload');
|
|
86
|
+
if (row.kind === 'metadata')
|
|
87
|
+
check(['metadata/MANUAL.md', 'metadata/catalog.json', 'metadata/manifest.json'].includes(item.path), 'unexpected metadata payload');
|
|
88
|
+
if (row.kind === 'bitmap')
|
|
89
|
+
check(new RegExp(`^icons/[a-z0-9]+(?:-[a-z0-9]+)*\\.${row.variant?.format}$`).test(item.path), 'bitmap package crosses tuple scope');
|
|
90
|
+
}
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
export async function consumeSelectedPackages(candidate, resourceKeys, io) {
|
|
94
|
+
const plan = planSelectedPackages(candidate, resourceKeys);
|
|
95
|
+
let networkBytes = 0, cacheHits = 0;
|
|
96
|
+
for (const row of plan) {
|
|
97
|
+
io.signal?.throwIfAborted();
|
|
98
|
+
let bytes = await io.readCache(row);
|
|
99
|
+
const cached = bytes !== undefined;
|
|
100
|
+
if (bytes !== undefined)
|
|
101
|
+
cacheHits++;
|
|
102
|
+
else {
|
|
103
|
+
bytes = await io.download(row);
|
|
104
|
+
networkBytes += bytes.length;
|
|
105
|
+
}
|
|
106
|
+
const files = verifySelectedPackage(row, bytes);
|
|
107
|
+
// Cache only validated complete packages before consumption, so a later
|
|
108
|
+
// target write failure can reuse this unit without another network fetch.
|
|
109
|
+
if (!cached)
|
|
110
|
+
await io.writeCache(row, bytes);
|
|
111
|
+
await io.consume(row, files);
|
|
112
|
+
}
|
|
113
|
+
return { packageCount: plan.length, networkBytes, cacheHits };
|
|
114
|
+
}
|