scripts/build-winget-portable-package.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { deflateRawSync } from "node:zlib";
import {
access,
cp,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { stageNpmPackage } from "./build-npm-package.js";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const supportedArchitectures = new Map([
["x64", 0x8664],
["arm64", 0xaa64],
]);
const zipDosDate = (1 << 5) | 1;
const zipDosTime = 0;
const maxZip32Value = 0xffffffff;
const semver =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:(?:0|[1-9]\d*)|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
function assertNonEmptyString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${label} must be a non-empty string.`);
}
return value;
}
function assertArchitecture(architecture) {
if (!supportedArchitectures.has(architecture)) {
throw new Error(`Unsupported Windows architecture: ${architecture}.`);
}
}
function writeUInt16(buffer, offset, value) {
buffer.writeUInt16LE(value, offset);
}
function writeUInt32(buffer, offset, value) {
buffer.writeUInt32LE(value >>> 0, offset);
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
export function crc32(contents) {
let value = 0xffffffff;
for (const byte of contents) {
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
function normalizeArchivePath(path) {
const normalized = path.split(sep).join("/");
if (
normalized === "" ||
normalized.startsWith("/") ||
normalized.split("/").some((part) => part === "" || part === "." || part === "..")
) {
throw new Error(`Unsafe ZIP entry path: ${path}`);
}
return normalized;
}
export function peMachine(contents, label = "Windows executable") {
if (contents.length < 0x40 || contents.toString("ascii", 0, 2) !== "MZ") {
throw new Error(`${label} is not a PE executable.`);
}
const peOffset = contents.readUInt32LE(0x3c);
if (
peOffset > contents.length - 6 ||
contents.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0"
) {
throw new Error(`${label} has an invalid PE header.`);
}
return contents.readUInt16LE(peOffset + 4);
}
export function validatePeArchitecture({ architecture, contents, label }) {
assertArchitecture(architecture);
const machine = peMachine(contents, label);
if (machine !== supportedArchitectures.get(architecture)) {
throw new Error(
`${label} is not built for ${architecture}; received PE machine 0x${machine.toString(16)}.`,
);
}
}
async function assertRegularFile(path, label) {
let metadata;
try {
metadata = await lstat(path);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`${label} does not exist: ${path}`);
}
throw error;
}
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`${label} must be a non-empty regular file: ${path}`);
}
}
async function assertDirectory(path, label) {
let metadata;
try {
metadata = await lstat(path);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`${label} does not exist: ${path}`);
}
throw error;
}
if (!metadata.isDirectory()) {
throw new Error(`${label} must be a directory: ${path}`);
}
}
export async function validateNodeRuntimeDirectory({ architecture, directory }) {
assertArchitecture(architecture);
const runtimeDirectory = resolve(directory);
const nodePath = join(runtimeDirectory, "node.exe");
const npxPath = join(
runtimeDirectory,
"node_modules",
"npm",
"bin",
"npx-cli.js",
);
await Promise.all([
assertRegularFile(nodePath, "Bundled node.exe"),
assertRegularFile(npxPath, "Bundled npm npx-cli.js"),
]);
validatePeArchitecture({
architecture,
contents: await readFile(nodePath),
label: "Bundled node.exe",
});
return runtimeDirectory;
}
export async function collectArchiveFiles(rootDirectory) {
const root = resolve(rootDirectory);
const files = [];
async function collect(directory) {
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Symbolic links are not permitted in a portable ZIP: ${path}`);
}
if (entry.isDirectory()) {
await collect(path);
continue;
}
if (!entry.isFile()) {
throw new Error(`Unsupported portable ZIP entry: ${path}`);
}
files.push({
contents: await readFile(path),
path: normalizeArchivePath(relative(root, path)),
});
}
}
await collect(root);
return files.sort((left, right) => left.path.localeCompare(right.path));
}
function zipEntry({ contents, path, offset }) {
const filename = Buffer.from(path, "utf8");
const compressed = deflateRawSync(contents, { level: 9 });
if (
filename.length > 0xffff ||
contents.length > maxZip32Value ||
compressed.length > maxZip32Value ||
offset > maxZip32Value
) {
throw new Error(`Portable ZIP entry exceeds the supported ZIP32 limit: ${path}`);
}
const checksum = crc32(contents);
const localHeader = Buffer.alloc(30);
writeUInt32(localHeader, 0, 0x04034b50);
writeUInt16(localHeader, 4, 20);
writeUInt16(localHeader, 6, 0);
writeUInt16(localHeader, 8, 8);
writeUInt16(localHeader, 10, zipDosTime);
writeUInt16(localHeader, 12, zipDosDate);
writeUInt32(localHeader, 14, checksum);
writeUInt32(localHeader, 18, compressed.length);
writeUInt32(localHeader, 22, contents.length);
writeUInt16(localHeader, 26, filename.length);
writeUInt16(localHeader, 28, 0);
const centralDirectoryHeader = Buffer.alloc(46);
writeUInt32(centralDirectoryHeader, 0, 0x02014b50);
writeUInt16(centralDirectoryHeader, 4, 20);
writeUInt16(centralDirectoryHeader, 6, 20);
writeUInt16(centralDirectoryHeader, 8, 0);
writeUInt16(centralDirectoryHeader, 10, 8);
writeUInt16(centralDirectoryHeader, 12, zipDosTime);
writeUInt16(centralDirectoryHeader, 14, zipDosDate);
writeUInt32(centralDirectoryHeader, 16, checksum);
writeUInt32(centralDirectoryHeader, 20, compressed.length);
writeUInt32(centralDirectoryHeader, 24, contents.length);
writeUInt16(centralDirectoryHeader, 28, filename.length);
writeUInt16(centralDirectoryHeader, 30, 0);
writeUInt16(centralDirectoryHeader, 32, 0);
writeUInt16(centralDirectoryHeader, 34, 0);
writeUInt16(centralDirectoryHeader, 36, 0);
writeUInt32(centralDirectoryHeader, 38, 0x81a40000);
writeUInt32(centralDirectoryHeader, 42, offset);
return {
centralDirectory: Buffer.concat([centralDirectoryHeader, filename]),
localContents: Buffer.concat([localHeader, filename, compressed]),
};
}
export function createDeterministicZip(files) {
if (!Array.isArray(files) || files.length === 0) {
throw new Error("A portable ZIP requires at least one file.");
}
if (files.length > 0xffff) {
throw new Error("Portable ZIP has too many entries for ZIP32.");
}
const sortedFiles = [...files]
.map(({ contents, path }) => ({
contents: Buffer.from(contents),
path: normalizeArchivePath(assertNonEmptyString(path, "ZIP entry path")),
}))
.sort((left, right) => left.path.localeCompare(right.path));
const duplicate = sortedFiles.find(
(entry, index) => index > 0 && entry.path === sortedFiles[index - 1].path,
);
if (duplicate) {
throw new Error(`Portable ZIP has a duplicate entry: ${duplicate.path}`);
}
const locals = [];
const centralDirectories = [];
let offset = 0;
for (const file of sortedFiles) {
const entry = zipEntry({ ...file, offset });
locals.push(entry.localContents);
centralDirectories.push(entry.centralDirectory);
offset += entry.localContents.length;
if (offset > maxZip32Value) {
throw new Error("Portable ZIP exceeds the supported ZIP32 limit.");
}
}
const centralDirectory = Buffer.concat(centralDirectories);
const endOfCentralDirectory = Buffer.alloc(22);
writeUInt32(endOfCentralDirectory, 0, 0x06054b50);
writeUInt16(endOfCentralDirectory, 4, 0);
writeUInt16(endOfCentralDirectory, 6, 0);
writeUInt16(endOfCentralDirectory, 8, sortedFiles.length);
writeUInt16(endOfCentralDirectory, 10, sortedFiles.length);
writeUInt32(endOfCentralDirectory, 12, centralDirectory.length);
writeUInt32(endOfCentralDirectory, 16, offset);
writeUInt16(endOfCentralDirectory, 20, 0);
return Buffer.concat([...locals, centralDirectory, endOfCentralDirectory]);
}
async function copyDirectoryContents(source, target) {
await assertDirectory(source, "Production node_modules directory");
await mkdir(target, { recursive: true });
const entries = await readdir(source, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === ".package-lock.json" || entry.name === ".cache") {
continue;
}
await cp(join(source, entry.name), join(target, entry.name), {
dereference: false,
recursive: entry.isDirectory(),
});
}
}
async function assertMissing(path) {
try {
await access(path);
} catch (error) {
if (error.code === "ENOENT") {
return;
}
throw error;
}
throw new Error(`Refusing to overwrite existing output: ${path}`);
}
export async function buildWingetPortablePackage({
architecture,
launcherPath,
nodeRuntimeDirectory,
outputPath,
productionNodeModulesDirectory = join(projectRoot, "node_modules"),
stagePackage = stageNpmPackage,
}) {
assertArchitecture(architecture);
const packageJson = JSON.parse(
await readFile(join(projectRoot, "package.json"), "utf8"),
);
const version = packageJson.version;
if (typeof version !== "string" || !semver.test(version)) {
throw new Error("package.json must contain a valid semantic version.");
}
const expectedFilename = `relmio-${version}-windows-${architecture}.zip`;
const resolvedOutputPath = resolve(outputPath);
if (basename(resolvedOutputPath) !== expectedFilename) {
throw new Error(
`Portable archive must be named ${expectedFilename}; received ${basename(resolvedOutputPath)}.`,
);
}
await assertMissing(resolvedOutputPath);
const resolvedLauncherPath = resolve(launcherPath);
await assertRegularFile(resolvedLauncherPath, "Relmio launcher");
validatePeArchitecture({
architecture,
contents: await readFile(resolvedLauncherPath),
label: "Relmio launcher",
});
const resolvedRuntimeDirectory = await validateNodeRuntimeDirectory({
architecture,
directory: nodeRuntimeDirectory,
});
const stagingDirectory = await mkdtemp(join(tmpdir(), "relmio-winget-"));
try {
const archiveRoot = join(stagingDirectory, `relmio-${version}-windows-${architecture}`);
const appNodeModules = join(archiveRoot, "app", "node_modules");
await mkdir(archiveRoot, { recursive: true });
await cp(resolvedLauncherPath, join(archiveRoot, "relmio.exe"));
await cp(resolvedRuntimeDirectory, join(archiveRoot, "runtime"), {
dereference: false,
recursive: true,
});
await stagePackage(join(appNodeModules, "relmio"));
await copyDirectoryContents(productionNodeModulesDirectory, appNodeModules);
await Promise.all([
assertRegularFile(
join(archiveRoot, "relmio.exe"),
"Packaged Relmio launcher",
),
assertRegularFile(
join(archiveRoot, "runtime", "node.exe"),
"Packaged node.exe",
),
assertRegularFile(
join(archiveRoot, "runtime", "node_modules", "npm", "bin", "npx-cli.js"),
"Packaged npm npx-cli.js",
),
assertRegularFile(
join(appNodeModules, "relmio", "src", "cli.js"),
"Packaged Relmio CLI",
),
assertDirectory(join(appNodeModules, "ssh2"), "Packaged ssh2 dependency"),
]);
await mkdir(dirname(resolvedOutputPath), { recursive: true });
const zip = createDeterministicZip(await collectArchiveFiles(archiveRoot));
await writeFile(resolvedOutputPath, zip, { flag: "wx" });
return {
outputPath: resolvedOutputPath,
sha256: createHash("sha256").update(zip).digest("hex"),
version,
};
} finally {
await rm(stagingDirectory, { force: true, recursive: true });
}
}
function parseArguments(argumentsList) {
const options = {};
const accepted = new Set([
"--architecture",
"--launcher",
"--node-runtime-dir",
"--output",
]);
for (let index = 0; index < argumentsList.length; index += 1) {
const argument = argumentsList[index];
if (!accepted.has(argument)) {
throw new Error(`Unknown argument: ${argument}`);
}
if (options[argument] !== undefined) {
throw new Error(`Argument supplied more than once: ${argument}`);
}
const value = argumentsList[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`${argument} requires a value.`);
}
options[argument] = value;
index += 1;
}
for (const required of accepted) {
if (options[required] === undefined) {
throw new Error(`${required} is required.`);
}
}
return options;
}
async function main() {
const options = parseArguments(process.argv.slice(2));
const result = await buildWingetPortablePackage({
architecture: options["--architecture"],
launcherPath: options["--launcher"],
nodeRuntimeDirectory: options["--node-runtime-dir"],
outputPath: options["--output"],
});
console.log(result.outputPath);
console.log(`SHA-256: ${result.sha256}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}