Ctrl K
ComunidadGuías…

scripts/build-winget-portable-package.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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;
  });
}