@oh-my-pi/pi-utils 17.2.8 → 17.2.10
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/CHANGELOG.md +32 -0
- package/dist/types/acp/connection.d.ts +118 -0
- package/dist/types/acp/protocol.d.ts +526 -0
- package/dist/types/acp/schema.d.ts +41 -0
- package/dist/types/acp/stream.d.ts +8 -0
- package/dist/types/acp/transport.d.ts +81 -0
- package/dist/types/acp.d.ts +6 -0
- package/dist/types/browsers.d.ts +68 -0
- package/dist/types/chalk.d.ts +125 -0
- package/dist/types/dates.d.ts +7 -0
- package/dist/types/docx/converter.d.ts +46 -0
- package/dist/types/docx/xml.d.ts +26 -0
- package/dist/types/docx/zip.d.ts +6 -0
- package/dist/types/docx.d.ts +11 -0
- package/dist/types/dom/core.d.ts +431 -0
- package/dist/types/dom/parser.d.ts +7 -0
- package/dist/types/dom/selector.d.ts +5 -0
- package/dist/types/dom.d.ts +5 -0
- package/dist/types/headers.d.ts +34 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/logger/rotating-file.d.ts +18 -0
- package/dist/types/lru.d.ts +46 -0
- package/dist/types/marked/core.d.ts +445 -0
- package/dist/types/marked.d.ts +2 -0
- package/dist/types/postmortem.d.ts +14 -0
- package/dist/types/procmgr.d.ts +16 -0
- package/dist/types/prompt.d.ts +2 -2
- package/dist/types/readability/readability.d.ts +9 -0
- package/dist/types/readability/readerable.d.ts +10 -0
- package/dist/types/readability/types.d.ts +70 -0
- package/dist/types/readability.d.ts +4 -0
- package/dist/types/template.d.ts +62 -0
- package/dist/types/turndown/gfm.d.ts +11 -0
- package/dist/types/turndown/html.d.ts +5 -0
- package/dist/types/turndown/service.d.ts +21 -0
- package/dist/types/turndown/types.d.ts +70 -0
- package/dist/types/turndown.d.ts +4 -0
- package/dist/types/version.d.ts +18 -0
- package/dist/types/vterm/buffer.d.ts +99 -0
- package/dist/types/vterm/terminal.d.ts +44 -0
- package/dist/types/vterm.d.ts +8 -0
- package/dist/types/xml.d.ts +31 -0
- package/package.json +2 -5
- package/src/acp/connection.ts +344 -0
- package/src/acp/protocol.ts +466 -0
- package/src/acp/schema.ts +160 -0
- package/src/acp/stream.ts +82 -0
- package/src/acp/transport.ts +213 -0
- package/src/acp.ts +6 -0
- package/src/browsers.ts +501 -0
- package/src/chalk.ts +312 -0
- package/src/dates.ts +194 -0
- package/src/docx/converter.ts +681 -0
- package/src/docx/xml.ts +166 -0
- package/src/docx/zip.ts +87 -0
- package/src/docx.ts +20 -0
- package/src/dom/core.ts +1254 -0
- package/src/dom/parser.ts +370 -0
- package/src/dom/selector.ts +290 -0
- package/src/dom.ts +33 -0
- package/src/fetch-retry.ts +7 -1
- package/src/frontmatter.ts +1 -1
- package/src/headers.ts +167 -0
- package/src/index.ts +1 -0
- package/src/logger/rotating-file.ts +149 -0
- package/src/logger.ts +12 -28
- package/src/lru.ts +185 -0
- package/src/marked/core.ts +1576 -0
- package/src/marked.ts +2 -0
- package/src/postmortem.ts +44 -1
- package/src/procmgr.ts +21 -4
- package/src/prompt.ts +5 -22
- package/src/readability/readability.ts +533 -0
- package/src/readability/readerable.ts +51 -0
- package/src/readability/types.ts +72 -0
- package/src/readability.ts +11 -0
- package/src/template.ts +586 -0
- package/src/turndown/gfm.ts +106 -0
- package/src/turndown/html.ts +257 -0
- package/src/turndown/service.ts +334 -0
- package/src/turndown/types.ts +81 -0
- package/src/turndown.ts +5 -0
- package/src/version.ts +99 -0
- package/src/vterm/buffer.ts +218 -0
- package/src/vterm/terminal.ts +773 -0
- package/src/vterm.ts +8 -0
- package/src/which.ts +6 -4
- package/src/xml.ts +313 -0
- package/src/winston-daily-rotate-file.d.ts +0 -6
package/src/browsers.ts
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/** Behavior-compatible reimplementation of @puppeteer/browsers' used surface. */
|
|
2
|
+
|
|
3
|
+
import type * as fs from "node:fs";
|
|
4
|
+
import * as fsp from "node:fs/promises";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as zlib from "node:zlib";
|
|
8
|
+
|
|
9
|
+
const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
|
|
10
|
+
const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
|
|
11
|
+
const ZIP_LOCAL_FILE_HEADER = 0x04034b50;
|
|
12
|
+
const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50;
|
|
13
|
+
const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50;
|
|
14
|
+
const ZIP_DIRECTORY_MODE = 0o040000;
|
|
15
|
+
const ZIP_REGULAR_FILE_MODE = 0o100000;
|
|
16
|
+
const ZIP_SYMLINK_MODE = 0o120000;
|
|
17
|
+
const ZIP_FILE_TYPE_MASK = 0o170000;
|
|
18
|
+
|
|
19
|
+
/** Supported browser products. */
|
|
20
|
+
export enum Browser {
|
|
21
|
+
CHROME = "chrome",
|
|
22
|
+
CHROMEHEADLESSSHELL = "chrome-headless-shell",
|
|
23
|
+
CHROMIUM = "chromium",
|
|
24
|
+
FIREFOX = "firefox",
|
|
25
|
+
CHROMEDRIVER = "chromedriver",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Browser download platform identifiers. */
|
|
29
|
+
export enum BrowserPlatform {
|
|
30
|
+
LINUX = "linux",
|
|
31
|
+
LINUX_ARM = "linux_arm",
|
|
32
|
+
MAC = "mac",
|
|
33
|
+
MAC_ARM = "mac_arm",
|
|
34
|
+
WIN32 = "win32",
|
|
35
|
+
WIN64 = "win64",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BROWSERS = [
|
|
39
|
+
Browser.CHROME,
|
|
40
|
+
Browser.CHROMEHEADLESSSHELL,
|
|
41
|
+
Browser.CHROMIUM,
|
|
42
|
+
Browser.FIREFOX,
|
|
43
|
+
Browser.CHROMEDRIVER,
|
|
44
|
+
] as const;
|
|
45
|
+
const BROWSER_PLATFORMS = [
|
|
46
|
+
BrowserPlatform.LINUX,
|
|
47
|
+
BrowserPlatform.LINUX_ARM,
|
|
48
|
+
BrowserPlatform.MAC,
|
|
49
|
+
BrowserPlatform.MAC_ARM,
|
|
50
|
+
BrowserPlatform.WIN32,
|
|
51
|
+
BrowserPlatform.WIN64,
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
|
|
55
|
+
export enum BrowserTag {
|
|
56
|
+
CANARY = "canary",
|
|
57
|
+
NIGHTLY = "nightly",
|
|
58
|
+
BETA = "beta",
|
|
59
|
+
DEV = "dev",
|
|
60
|
+
DEVEDITION = "devedition",
|
|
61
|
+
STABLE = "stable",
|
|
62
|
+
ESR = "esr",
|
|
63
|
+
LATEST = "latest",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Download progress reported while a browser archive is streamed to disk. */
|
|
67
|
+
export interface BrowserDownloadProgress {
|
|
68
|
+
downloadedBytes: number;
|
|
69
|
+
totalBytes: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inputs used to locate an installed browser executable. */
|
|
73
|
+
export interface ComputeExecutablePathOptions {
|
|
74
|
+
browser: Browser;
|
|
75
|
+
buildId: string;
|
|
76
|
+
cacheDir: string;
|
|
77
|
+
platform?: BrowserPlatform;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Inputs used to download and install a browser. */
|
|
81
|
+
export interface InstallOptions extends ComputeExecutablePathOptions {
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Metadata for one browser installation found in a Puppeteer cache. */
|
|
87
|
+
export interface InstalledBrowser {
|
|
88
|
+
browser: Browser;
|
|
89
|
+
buildId: string;
|
|
90
|
+
platform: BrowserPlatform;
|
|
91
|
+
path: string;
|
|
92
|
+
executablePath: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface LastKnownGoodVersions {
|
|
96
|
+
channels: Record<string, { version: string }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface MilestoneVersions {
|
|
100
|
+
milestones: Record<string, { version: string }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface PatchVersions {
|
|
104
|
+
builds: Record<string, { version: string }>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ZipEntry {
|
|
108
|
+
name: string;
|
|
109
|
+
method: number;
|
|
110
|
+
crc: number;
|
|
111
|
+
compressedSize: number;
|
|
112
|
+
uncompressedSize: number;
|
|
113
|
+
externalAttributes: number;
|
|
114
|
+
localHeaderOffset: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Detect the current host's Puppeteer browser platform. */
|
|
118
|
+
export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
119
|
+
const platform = os.platform();
|
|
120
|
+
const arch = os.arch();
|
|
121
|
+
if (platform === "darwin") return arch === "arm64" ? BrowserPlatform.MAC_ARM : BrowserPlatform.MAC;
|
|
122
|
+
if (platform === "linux") return arch === "arm64" ? BrowserPlatform.LINUX_ARM : BrowserPlatform.LINUX;
|
|
123
|
+
if (platform === "win32") return arch === "ia32" ? BrowserPlatform.WIN32 : BrowserPlatform.WIN64;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
|
|
128
|
+
export async function resolveBuildId(
|
|
129
|
+
browser: Browser,
|
|
130
|
+
_platform: BrowserPlatform,
|
|
131
|
+
tag: string | BrowserTag,
|
|
132
|
+
): Promise<string> {
|
|
133
|
+
if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
|
|
134
|
+
return tag;
|
|
135
|
+
}
|
|
136
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
|
|
137
|
+
|
|
138
|
+
const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
|
|
139
|
+
if (channel) {
|
|
140
|
+
const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
|
|
141
|
+
const version = metadata.channels[channel]?.version;
|
|
142
|
+
if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
|
|
143
|
+
return version;
|
|
144
|
+
}
|
|
145
|
+
if (/^\d+$/.test(tag)) {
|
|
146
|
+
const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
|
|
147
|
+
return metadata.milestones[tag]?.version ?? tag;
|
|
148
|
+
}
|
|
149
|
+
if (/^\d+\.\d+\.\d+$/.test(tag)) {
|
|
150
|
+
const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
|
|
151
|
+
return metadata.builds[tag]?.version ?? tag;
|
|
152
|
+
}
|
|
153
|
+
return tag;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Return the Chrome-for-Testing archive URL for a browser build. */
|
|
157
|
+
export function getDownloadUrl(
|
|
158
|
+
browser: Browser,
|
|
159
|
+
platform: BrowserPlatform,
|
|
160
|
+
buildId: string,
|
|
161
|
+
baseUrl = CHROME_FOR_TESTING_BASE_URL,
|
|
162
|
+
): URL {
|
|
163
|
+
if (browser !== Browser.CHROME) throw new Error(`Unsupported browser download: ${browser}`);
|
|
164
|
+
const archivePlatform = chromeArchivePlatform(platform);
|
|
165
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
166
|
+
return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Compute the executable path in Puppeteer's cache layout. */
|
|
170
|
+
export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
|
|
171
|
+
const platform = options.platform ?? detectBrowserPlatform();
|
|
172
|
+
if (!platform) throw new Error("Cannot determine a browser platform for this host");
|
|
173
|
+
if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
|
|
174
|
+
const installDir = installationDir(options.cacheDir, options.browser, platform, options.buildId);
|
|
175
|
+
switch (platform) {
|
|
176
|
+
case BrowserPlatform.LINUX:
|
|
177
|
+
case BrowserPlatform.LINUX_ARM:
|
|
178
|
+
return path.join(installDir, "chrome-linux64", "chrome");
|
|
179
|
+
case BrowserPlatform.MAC:
|
|
180
|
+
return path.join(
|
|
181
|
+
installDir,
|
|
182
|
+
"chrome-mac-x64",
|
|
183
|
+
"Google Chrome for Testing.app",
|
|
184
|
+
"Contents",
|
|
185
|
+
"MacOS",
|
|
186
|
+
"Google Chrome for Testing",
|
|
187
|
+
);
|
|
188
|
+
case BrowserPlatform.MAC_ARM:
|
|
189
|
+
return path.join(
|
|
190
|
+
installDir,
|
|
191
|
+
"chrome-mac-arm64",
|
|
192
|
+
"Google Chrome for Testing.app",
|
|
193
|
+
"Contents",
|
|
194
|
+
"MacOS",
|
|
195
|
+
"Google Chrome for Testing",
|
|
196
|
+
);
|
|
197
|
+
case BrowserPlatform.WIN32:
|
|
198
|
+
return path.join(installDir, "chrome-win32", "chrome.exe");
|
|
199
|
+
case BrowserPlatform.WIN64:
|
|
200
|
+
return path.join(installDir, "chrome-win64", "chrome.exe");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Scan a Puppeteer cache for browser installation directories. */
|
|
205
|
+
export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
|
|
206
|
+
const installed: InstalledBrowser[] = [];
|
|
207
|
+
for (const browser of BROWSERS) {
|
|
208
|
+
const browserDir = path.join(options.cacheDir, browser);
|
|
209
|
+
let entries: fs.Dirent[];
|
|
210
|
+
try {
|
|
211
|
+
entries = await fsp.readdir(browserDir, { withFileTypes: true });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (isMissingPath(error)) continue;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (!entry.isDirectory()) continue;
|
|
218
|
+
const parsed = parseInstallationName(entry.name);
|
|
219
|
+
if (!parsed) continue;
|
|
220
|
+
const installPath = path.join(browserDir, entry.name);
|
|
221
|
+
try {
|
|
222
|
+
installed.push({
|
|
223
|
+
browser,
|
|
224
|
+
buildId: parsed.buildId,
|
|
225
|
+
platform: parsed.platform,
|
|
226
|
+
path: installPath,
|
|
227
|
+
executablePath: computeExecutablePath({
|
|
228
|
+
browser,
|
|
229
|
+
buildId: parsed.buildId,
|
|
230
|
+
cacheDir: options.cacheDir,
|
|
231
|
+
platform: parsed.platform,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
// Other browser products are not part of the surface used by OMP.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return installed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Download and unpack Chrome into Puppeteer's existing cache layout. */
|
|
243
|
+
export async function install(options: InstallOptions): Promise<InstalledBrowser> {
|
|
244
|
+
const platform = options.platform ?? detectBrowserPlatform();
|
|
245
|
+
if (!platform) throw new Error("Cannot determine a browser platform for this host");
|
|
246
|
+
const executablePath = computeExecutablePath({ ...options, platform });
|
|
247
|
+
const installPath = installationDir(options.cacheDir, options.browser, platform, options.buildId);
|
|
248
|
+
if (await pathExists(executablePath)) {
|
|
249
|
+
return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await fsp.mkdir(options.cacheDir, { recursive: true });
|
|
253
|
+
const nonce = `${process.pid}-${crypto.randomUUID()}`;
|
|
254
|
+
const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
|
|
255
|
+
const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
|
|
256
|
+
try {
|
|
257
|
+
await downloadArchive(
|
|
258
|
+
getDownloadUrl(options.browser, platform, options.buildId, options.baseUrl),
|
|
259
|
+
archivePath,
|
|
260
|
+
options.downloadProgressCallback,
|
|
261
|
+
);
|
|
262
|
+
await extractZipArchive(archivePath, stagingPath);
|
|
263
|
+
await fsp.mkdir(path.dirname(installPath), { recursive: true });
|
|
264
|
+
await fsp.rm(installPath, { recursive: true, force: true });
|
|
265
|
+
await fsp.rename(stagingPath, installPath);
|
|
266
|
+
} finally {
|
|
267
|
+
await Promise.all([
|
|
268
|
+
fsp.rm(archivePath, { force: true }).catch(() => {}),
|
|
269
|
+
fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
if (!(await pathExists(executablePath)))
|
|
273
|
+
throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
|
|
274
|
+
return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function chromeChannelName(tag: string): string | undefined {
|
|
278
|
+
switch (tag) {
|
|
279
|
+
case BrowserTag.STABLE:
|
|
280
|
+
return "Stable";
|
|
281
|
+
case BrowserTag.BETA:
|
|
282
|
+
return "Beta";
|
|
283
|
+
case BrowserTag.DEV:
|
|
284
|
+
return "Dev";
|
|
285
|
+
case BrowserTag.CANARY:
|
|
286
|
+
return "Canary";
|
|
287
|
+
default:
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function fetchMetadata<T>(filename: string): Promise<T> {
|
|
293
|
+
const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`);
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
|
|
296
|
+
return (await response.json()) as T;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
300
|
+
switch (platform) {
|
|
301
|
+
case BrowserPlatform.LINUX:
|
|
302
|
+
case BrowserPlatform.LINUX_ARM:
|
|
303
|
+
return "linux64";
|
|
304
|
+
case BrowserPlatform.MAC:
|
|
305
|
+
return "mac-x64";
|
|
306
|
+
case BrowserPlatform.MAC_ARM:
|
|
307
|
+
return "mac-arm64";
|
|
308
|
+
case BrowserPlatform.WIN32:
|
|
309
|
+
return "win32";
|
|
310
|
+
case BrowserPlatform.WIN64:
|
|
311
|
+
return "win64";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function installationDir(cacheDir: string, browser: Browser, platform: BrowserPlatform, buildId: string): string {
|
|
316
|
+
return path.join(cacheDir, browser, `${platform}-${buildId}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
|
|
320
|
+
for (const platform of BROWSER_PLATFORMS) {
|
|
321
|
+
const prefix = `${platform}-`;
|
|
322
|
+
if (name.startsWith(prefix) && name.length > prefix.length)
|
|
323
|
+
return { platform, buildId: name.slice(prefix.length) };
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isMissingPath(error: unknown): boolean {
|
|
329
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
333
|
+
try {
|
|
334
|
+
await fsp.access(filePath);
|
|
335
|
+
return true;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (isMissingPath(error)) return false;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function downloadArchive(
|
|
343
|
+
url: URL,
|
|
344
|
+
destination: string,
|
|
345
|
+
onProgress: ((progress: BrowserDownloadProgress) => void) | undefined,
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
const response = await fetch(url);
|
|
348
|
+
if (!response.ok || !response.body) {
|
|
349
|
+
throw new Error(`Browser download failed (${response.status} ${response.statusText}) from ${url}`);
|
|
350
|
+
}
|
|
351
|
+
const totalBytes = Number(response.headers.get("content-length") ?? 0);
|
|
352
|
+
const file = await fsp.open(destination, "wx");
|
|
353
|
+
let downloadedBytes = 0;
|
|
354
|
+
try {
|
|
355
|
+
const reader = response.body.getReader();
|
|
356
|
+
for (;;) {
|
|
357
|
+
const chunk = await reader.read();
|
|
358
|
+
if (chunk.done) break;
|
|
359
|
+
let offset = 0;
|
|
360
|
+
while (offset < chunk.value.byteLength) {
|
|
361
|
+
const write = await file.write(chunk.value, offset, chunk.value.byteLength - offset, null);
|
|
362
|
+
if (write.bytesWritten === 0) throw new Error(`Browser download stalled while writing ${destination}`);
|
|
363
|
+
offset += write.bytesWritten;
|
|
364
|
+
}
|
|
365
|
+
downloadedBytes += chunk.value.byteLength;
|
|
366
|
+
onProgress?.({ downloadedBytes, totalBytes });
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
await file.close();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function extractZipArchive(archivePath: string, destination: string): Promise<void> {
|
|
374
|
+
const archive = await fsp.readFile(archivePath);
|
|
375
|
+
const entries = readCentralDirectory(archive);
|
|
376
|
+
await fsp.mkdir(destination, { recursive: true });
|
|
377
|
+
const root = path.resolve(destination);
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
const outputPath = safeArchivePath(root, entry.name);
|
|
380
|
+
const mode = entry.externalAttributes >>> 16;
|
|
381
|
+
const type = mode & ZIP_FILE_TYPE_MASK;
|
|
382
|
+
if (entry.name.endsWith("/") || type === ZIP_DIRECTORY_MODE) {
|
|
383
|
+
await fsp.mkdir(outputPath, { recursive: true });
|
|
384
|
+
if (mode & 0o777) await fsp.chmod(outputPath, mode & 0o777);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const contents = readZipEntry(archive, entry);
|
|
389
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
390
|
+
if (type === ZIP_SYMLINK_MODE) {
|
|
391
|
+
const target = contents.toString("utf8");
|
|
392
|
+
validateSymlinkTarget(root, outputPath, target);
|
|
393
|
+
await fsp.symlink(target, outputPath);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
await fsp.writeFile(outputPath, contents, { mode: mode & 0o777 ? mode & 0o777 : 0o644 });
|
|
397
|
+
if ((mode & ZIP_FILE_TYPE_MASK) === ZIP_REGULAR_FILE_MODE && mode & 0o777)
|
|
398
|
+
await fsp.chmod(outputPath, mode & 0o777);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readCentralDirectory(archive: Buffer): ZipEntry[] {
|
|
403
|
+
const minimumOffset = Math.max(0, archive.length - 65_557);
|
|
404
|
+
let endOffset = -1;
|
|
405
|
+
for (let offset = archive.length - 22; offset >= minimumOffset; offset--) {
|
|
406
|
+
if (archive.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY) {
|
|
407
|
+
endOffset = offset;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (endOffset < 0) throw new Error("Invalid ZIP archive: central directory was not found");
|
|
412
|
+
const disk = archive.readUInt16LE(endOffset + 4);
|
|
413
|
+
const centralDisk = archive.readUInt16LE(endOffset + 6);
|
|
414
|
+
const entryCount = archive.readUInt16LE(endOffset + 10);
|
|
415
|
+
const centralSize = archive.readUInt32LE(endOffset + 12);
|
|
416
|
+
const centralOffset = archive.readUInt32LE(endOffset + 16);
|
|
417
|
+
if (disk !== 0 || centralDisk !== 0) throw new Error("Multi-disk ZIP archives are not supported");
|
|
418
|
+
if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) {
|
|
419
|
+
throw new Error("ZIP64 archives are not supported");
|
|
420
|
+
}
|
|
421
|
+
if (centralOffset + centralSize > endOffset)
|
|
422
|
+
throw new Error("Invalid ZIP archive: central directory is out of bounds");
|
|
423
|
+
|
|
424
|
+
const entries: ZipEntry[] = [];
|
|
425
|
+
let offset = centralOffset;
|
|
426
|
+
for (let index = 0; index < entryCount; index++) {
|
|
427
|
+
if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_HEADER) {
|
|
428
|
+
throw new Error("Invalid ZIP archive: malformed central directory entry");
|
|
429
|
+
}
|
|
430
|
+
const flags = archive.readUInt16LE(offset + 8);
|
|
431
|
+
if (flags & 1) throw new Error("Encrypted ZIP entries are not supported");
|
|
432
|
+
const nameLength = archive.readUInt16LE(offset + 28);
|
|
433
|
+
const extraLength = archive.readUInt16LE(offset + 30);
|
|
434
|
+
const commentLength = archive.readUInt16LE(offset + 32);
|
|
435
|
+
const end = offset + 46 + nameLength + extraLength + commentLength;
|
|
436
|
+
if (end > archive.length) throw new Error("Invalid ZIP archive: truncated central directory entry");
|
|
437
|
+
entries.push({
|
|
438
|
+
name: archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"),
|
|
439
|
+
method: archive.readUInt16LE(offset + 10),
|
|
440
|
+
crc: archive.readUInt32LE(offset + 16),
|
|
441
|
+
compressedSize: archive.readUInt32LE(offset + 20),
|
|
442
|
+
uncompressedSize: archive.readUInt32LE(offset + 24),
|
|
443
|
+
externalAttributes: archive.readUInt32LE(offset + 38),
|
|
444
|
+
localHeaderOffset: archive.readUInt32LE(offset + 42),
|
|
445
|
+
});
|
|
446
|
+
offset = end;
|
|
447
|
+
}
|
|
448
|
+
return entries;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function readZipEntry(archive: Buffer, entry: ZipEntry): Buffer {
|
|
452
|
+
const offset = entry.localHeaderOffset;
|
|
453
|
+
if (offset + 30 > archive.length || archive.readUInt32LE(offset) !== ZIP_LOCAL_FILE_HEADER) {
|
|
454
|
+
throw new Error(`Invalid ZIP archive: malformed local header for ${entry.name}`);
|
|
455
|
+
}
|
|
456
|
+
const nameLength = archive.readUInt16LE(offset + 26);
|
|
457
|
+
const extraLength = archive.readUInt16LE(offset + 28);
|
|
458
|
+
const start = offset + 30 + nameLength + extraLength;
|
|
459
|
+
const end = start + entry.compressedSize;
|
|
460
|
+
if (end > archive.length) throw new Error(`Invalid ZIP archive: truncated data for ${entry.name}`);
|
|
461
|
+
const compressed = archive.subarray(start, end);
|
|
462
|
+
let contents: Buffer;
|
|
463
|
+
if (entry.method === 0) contents = Buffer.from(compressed);
|
|
464
|
+
else if (entry.method === 8) contents = zlib.inflateRawSync(compressed);
|
|
465
|
+
else throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}`);
|
|
466
|
+
if (contents.length !== entry.uncompressedSize)
|
|
467
|
+
throw new Error(`Invalid uncompressed size for ZIP entry ${entry.name}`);
|
|
468
|
+
if (crc32(contents) !== entry.crc) throw new Error(`CRC mismatch for ZIP entry ${entry.name}`);
|
|
469
|
+
return contents;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function safeArchivePath(root: string, name: string): string {
|
|
473
|
+
if (!name || name.includes("\0") || name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) {
|
|
474
|
+
throw new Error(`Unsafe path in ZIP archive: ${name}`);
|
|
475
|
+
}
|
|
476
|
+
const segments = name.replaceAll("\\", "/").split("/");
|
|
477
|
+
if (segments.some(segment => segment === "..")) throw new Error(`Unsafe path in ZIP archive: ${name}`);
|
|
478
|
+
const target = path.resolve(root, ...segments.filter(Boolean));
|
|
479
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
480
|
+
throw new Error(`Unsafe path in ZIP archive: ${name}`);
|
|
481
|
+
return target;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function validateSymlinkTarget(root: string, linkPath: string, target: string): void {
|
|
485
|
+
if (!target || target.includes("\0") || path.isAbsolute(target) || /^[A-Za-z]:/.test(target)) {
|
|
486
|
+
throw new Error(`Unsafe symlink target in ZIP archive: ${target}`);
|
|
487
|
+
}
|
|
488
|
+
const resolved = path.resolve(path.dirname(linkPath), target);
|
|
489
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
490
|
+
throw new Error(`Unsafe symlink target in ZIP archive: ${target}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function crc32(contents: Uint8Array): number {
|
|
495
|
+
let crc = 0xffffffff;
|
|
496
|
+
for (const byte of contents) {
|
|
497
|
+
crc ^= byte;
|
|
498
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
499
|
+
}
|
|
500
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
501
|
+
}
|