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