Ctrl K
ComunidadGuías…

scripts/package-runtime.mjs

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

Archivo original ↗Preguntar a KiBot
import { spawnSync } from "node:child_process";
import {
  chmod,
  copyFile,
  cp,
  mkdir,
  readFile,
  readdir,
  realpath,
  rm,
  stat,
  writeFile
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
  buildWindowsHostCompilePowerShellCommand,
  defaultWindowsPowerShellPath,
  encodePowerShellCommand
} from "./autostart-core.mjs";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");

function option(args, name) {
  const index = args.indexOf(name);
  if (index < 0) return undefined;
  const value = args[index + 1];
  if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`);
  return value;
}

function run(command, args, cwd = repoRoot) {
  const result = spawnSync(command, args, {
    cwd,
    stdio: "inherit",
    windowsHide: true
  });
  if (result.error) throw result.error;
  if (result.status !== 0) {
    throw new Error(`${command} failed with exit code ${result.status}`);
  }
}

async function requireFile(filePath, label) {
  const info = await stat(filePath).catch(() => undefined);
  if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`);
}

function gitCommit() {
  const fromEnvironment = process.env.GITHUB_SHA?.trim();
  if (fromEnvironment && /^[0-9a-f]{40}$/u.test(fromEnvironment)) {
    return fromEnvironment;
  }
  const result = spawnSync("git", ["rev-parse", "HEAD"], {
    cwd: repoRoot,
    encoding: "utf8",
    windowsHide: true
  });
  if (result.status !== 0) throw new Error("Unable to resolve Git commit");
  const value = result.stdout.trim();
  if (!/^[0-9a-f]{40}$/u.test(value)) throw new Error("Git returned an invalid commit SHA");
  return value;
}

async function findNodeLicense() {
  const base = path.dirname(process.execPath);
  const candidates = [
    path.join(base, "LICENSE"),
    path.resolve(base, "..", "LICENSE"),
    path.resolve(base, "..", "..", "LICENSE")
  ];
  for (const candidate of candidates) {
    if (await stat(candidate).then((value) => value.isFile(), () => false)) {
      return candidate;
    }
  }
  throw new Error("Unable to locate the bundled Node.js LICENSE file");
}

async function collectPackageComponents(nodeModulesRoot, palmttyVersion) {
  const components = new Map();
  const visited = new Set();

  async function walk(directory) {
    const resolved = await realpath(directory).catch(() => undefined);
    if (!resolved || visited.has(resolved)) return;
    visited.add(resolved);

    const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
    for (const entry of entries) {
      if (entry.name === ".bin") continue;
      const child = path.join(directory, entry.name);
      if (entry.name === "package.json" && entry.isFile()) {
        try {
          const pkg = JSON.parse(await readFile(child, "utf8"));
          if (typeof pkg.name !== "string" || pkg.name.length === 0) continue;
          const version =
            typeof pkg.version === "string" && pkg.version.length > 0
              ? pkg.version
              : pkg.name.startsWith("@palmtty/")
                ? palmttyVersion
                : undefined;
          if (!version) continue;
          const key = `${pkg.name}@${version}`;
          const purlName = pkg.name
            .split("/")
            .map((part) => encodeURIComponent(part))
            .join("/");
          components.set(key, {
            type: "library",
            "bom-ref": `pkg:npm/${purlName}@${encodeURIComponent(version)}`,
            name: pkg.name,
            version,
            purl: `pkg:npm/${purlName}@${encodeURIComponent(version)}`
          });
        } catch {
          // A malformed dependency package manifest will fail Node resolution
          // later; SBOM collection itself stays best-effort per directory.
        }
        continue;
      }

      const info = entry.isDirectory() || entry.isSymbolicLink()
        ? await stat(child).catch(() => undefined)
        : undefined;
      if (info?.isDirectory()) await walk(child);
    }
  }

  await walk(nodeModulesRoot);
  return [...components.values()].sort((a, b) =>
    a["bom-ref"].localeCompare(b["bom-ref"])
  );
}

async function writeSbom(root, manifest) {
  const application = await collectPackageComponents(
    path.join(root, "app", "node_modules"), manifest.version
  );
  const components = application;
  const rootRef = `pkg:generic/palmtty@${encodeURIComponent(manifest.version)}`;
  const sbom = {
    bomFormat: "CycloneDX",
    specVersion: "1.6",
    version: 1,
    metadata: {
      component: {
        type: "application",
        "bom-ref": rootRef,
        name: "PalmTTY",
        version: manifest.version
      },
      properties: [
        { name: "palmtty:commit", value: manifest.commit },
        { name: "palmtty:platform", value: manifest.platform },
        { name: "palmtty:arch", value: manifest.arch },
        { name: "palmtty:nodeVersion", value: manifest.nodeVersion }
      ]
    },
    components,
    dependencies: [
      {
        ref: rootRef,
        dependsOn: components.map((component) => component["bom-ref"])
      }
    ]
  };
  await writeFile(
    path.join(root, "sbom.cdx.json"),
    `${JSON.stringify(sbom, null, 2)}\n`,
    "utf8"
  );
}

async function compileWindowsServiceHost(outputPath) {
  const sourcePath = path.join(repoRoot, "scripts", "windows-autostart-host.cs");
  await requireFile(sourcePath, "Windows service host source");
  const powershell = defaultWindowsPowerShellPath();
  await requireFile(powershell, "Windows PowerShell");
  const command = buildWindowsHostCompilePowerShellCommand({
    sourcePath,
    outputPath
  });
  run(powershell, [
    "-NoLogo",
    "-NoProfile",
    "-NonInteractive",
    "-EncodedCommand",
    encodePowerShellCommand(command)
  ]);
  await requireFile(outputPath, "Compiled Windows service host");
}

function quotePowerShellLiteral(value) {
  if (/\r|\n|[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(value)) {
    throw new Error("PowerShell path contains an unsupported control character");
  }
  return `'${value.replaceAll("'", "''")}'`;
}

async function main() {
  const args = process.argv.slice(2);
  const outputValue = option(args, "--out");
  const platform = option(args, "--platform") ?? process.platform;
  const arch = option(args, "--arch") ?? process.arch;
  if (!outputValue) {
    throw new Error(
      "Usage: node scripts/package-runtime.mjs --out <dir> [--platform <platform>] [--arch <arch>]"
    );
  }
  if (platform !== process.platform) {
    throw new Error(
      `PalmTTY runtime packages must be built natively: requested ${platform}, runner is ${process.platform}`
    );
  }
  if (arch !== process.arch) {
    throw new Error(
      `PalmTTY runtime packages must match the runner architecture: requested ${arch}, runner is ${process.arch}`
    );
  }
  if (!["win32", "linux"].includes(platform)) {
    throw new Error(`Distribution packaging is not implemented for ${platform}`);
  }

  // Never ship a PTY native-addon bundle from the wrong Node ABI.
  run(process.execPath, [path.join(repoRoot, "scripts", "node-runtime-check.mjs")]);

  const outputRoot = path.resolve(outputValue);
  const rootPackage = JSON.parse(
    await readFile(path.join(repoRoot, "package.json"), "utf8")
  );
  const version = rootPackage.version;
  if (
    typeof version !== "string" ||
    !/^\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?$/u.test(version)
  ) {
    throw new Error("Root package.json contains an invalid PalmTTY version");
  }

  await rm(outputRoot, { recursive: true, force: true });
  await mkdir(outputRoot, { recursive: true });

  const pnpmCli = process.env.npm_execpath;
  if (!pnpmCli || !path.isAbsolute(pnpmCli)) {
    throw new Error(
      "PalmTTY distribution packaging must be launched through pnpm package:runtime"
    );
  }
  const appRoot = path.join(outputRoot, "app");
  run(process.execPath, [
    pnpmCli,
    "--config.node-linker=hoisted",
    "--filter",
    "@palmtty/agent",
    "--prod",
    "deploy",
    "--legacy",
    appRoot
  ]);

  await cp(
    path.join(repoRoot, "apps", "web", "dist"),
    path.join(outputRoot, "web"),
    { recursive: true }
  );

  const runtimeDir = path.join(outputRoot, "runtime");
  const toolsDir = path.join(outputRoot, "tools");
  const defaultsDir = path.join(outputRoot, "defaults");
  const binDir = path.join(outputRoot, "bin");
  await Promise.all([
    mkdir(runtimeDir, { recursive: true }),
    mkdir(toolsDir, { recursive: true }),
    mkdir(defaultsDir, { recursive: true }),
    mkdir(binDir, { recursive: true })
  ]);

  const bundledNode = path.join(
    runtimeDir,
    platform === "win32" ? "node.exe" : "node"
  );
  await copyFile(process.execPath, bundledNode);
  if (platform !== "win32") await chmod(bundledNode, 0o755);
  await Promise.all([
    copyFile(
      path.join(repoRoot, "scripts", "installed-cli.mjs"),
      path.join(toolsDir, "installed-cli.mjs")
    ),
    copyFile(
      path.join(repoRoot, "scripts", "autostart-core.mjs"),
      path.join(toolsDir, "autostart-core.mjs")
    ),
    copyFile(
      path.join(repoRoot, "scripts", "qwen3-tts-server.py"),
      path.join(toolsDir, "qwen3-tts-server.py")
    ),
    copyFile(
      path.join(repoRoot, "packaging", "default-config.yaml"),
      path.join(defaultsDir, "config.yaml")
    ),
    copyFile(
      path.join(repoRoot, "LICENSE"),
      path.join(outputRoot, "LICENSE")
    ),
    copyFile(
      await findNodeLicense(),
      path.join(outputRoot, "NODE-LICENSE")
    )
  ]);

  const manifest = {
    schemaVersion: 1,
    name: "PalmTTY",
    version,
    commit: gitCommit(),
    channel: version.includes("-") ? "prerelease" : "stable",
    platform,
    arch,
    nodeVersion: process.version
  };
  await writeFile(
    path.join(outputRoot, "release-manifest.json"),
    `${JSON.stringify(manifest, null, 2)}\n`,
    "utf8"
  );

  if (platform === "win32") {
    await compileWindowsServiceHost(
      path.join(binDir, "palmtty-autostart-host.exe")
    );
    await writeFile(
      path.join(binDir, "palmtty.cmd"),
      [
        "@echo off",
        "setlocal",
        'set "PALMTTY_INSTALL_ROOT=%~dp0.."',
        '"%PALMTTY_INSTALL_ROOT%\\runtime\\node.exe" "%PALMTTY_INSTALL_ROOT%\\tools\\installed-cli.mjs" %*',
        "exit /b %ERRORLEVEL%",
        ""
      ].join("\r\n"),
      "utf8"
    );
  } else {
    const launcher = `#!/bin/sh
set -eu
SELF="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")"
PALMTTY_INSTALL_ROOT="$(CDPATH= cd -- "$(dirname -- "$SELF")/.." && pwd)"
export PALMTTY_INSTALL_ROOT
exec "$PALMTTY_INSTALL_ROOT/runtime/node" "$PALMTTY_INSTALL_ROOT/tools/installed-cli.mjs" "$@"
`;
    const launcherPath = path.join(binDir, "palmtty");
    await writeFile(launcherPath, launcher, { encoding: "utf8", mode: 0o755 });
    await chmod(launcherPath, 0o755);
  }

  const forbiddenSourcePaths = [
    path.join(appRoot, "src"),
    path.join(appRoot, "node_modules", "@palmtty", "config", "src"),
    path.join(appRoot, "node_modules", "@palmtty", "protocol", "src")
  ];
  for (const forbidden of forbiddenSourcePaths) {
    if (await stat(forbidden).then(() => true, () => false)) {
      throw new Error(
        `Production deployment unexpectedly contains workspace source: ${forbidden}`
      );
    }
  }

  await writeSbom(outputRoot, manifest);
  console.log(
    `[PalmTTY] packaged ${version} for ${platform}-${arch}: ${outputRoot}`
  );
}

main().catch((error) => {
  console.error(
    `[PalmTTY] package failed: ${error instanceof Error ? error.message : error}`
  );
  process.exitCode = 1;
});