396 lines
18 KiB
JavaScript
396 lines
18 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { createRequire } from "node:module";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmdirSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
|
import { frozenRuntime } from "../frozen-versions.mjs";
|
|
|
|
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
|
const fixedPort = 43121;
|
|
const textExtensions = new Set([".cjs", ".css", ".html", ".js", ".json", ".mjs", ".txt", ".xml"]);
|
|
const developmentOnlyDependencyDirectories = new Set([".github", "benchmark", "benchmarks", "docs", "examples", "test", "tests"]);
|
|
|
|
function debug(message) {
|
|
if (process.env.DADA_PACKAGE_DEBUG === "1") process.stderr.write(`[portable-package] ${message}\n`);
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const executable = process.platform === "win32" && command === "pnpm" ? (process.env.ComSpec ?? "cmd.exe") : command;
|
|
const actualArgs = executable === command ? args : ["/d", "/s", "/c", [command, ...args].join(" ")];
|
|
const result = spawnSync(executable, actualArgs, { cwd: repositoryRoot, encoding: "utf8", stdio: "pipe", ...options });
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed:\n${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
}
|
|
return result.stdout?.trim() ?? "";
|
|
}
|
|
|
|
function ensureBuildOutput(path) {
|
|
const buildRoot = resolve(repositoryRoot, ".build");
|
|
const resolved = resolve(path);
|
|
if (resolved !== buildRoot && !resolved.startsWith(`${buildRoot}${sep}`)) {
|
|
throw new Error("Portable package output must remain under the repository .build directory.");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function json(path) {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
}
|
|
|
|
function writeJson(path, value) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function copyTree(source, destination, filter = () => true) {
|
|
if (!filter(source)) return;
|
|
const attributes = statSync(source);
|
|
if (attributes.isDirectory()) {
|
|
mkdirSync(destination, { recursive: true });
|
|
for (const entry of readdirSync(source)) {
|
|
copyTree(join(source, entry), join(destination, entry), filter);
|
|
}
|
|
return;
|
|
}
|
|
if (attributes.isFile()) copyFileSync(source, destination);
|
|
}
|
|
|
|
function removeTree(path) {
|
|
if (!existsSync(path)) return;
|
|
const attributes = lstatSync(path);
|
|
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
|
unlinkSync(path);
|
|
return;
|
|
}
|
|
for (const entry of readdirSync(path)) removeTree(join(path, entry));
|
|
rmdirSync(path);
|
|
}
|
|
|
|
function packageRootFromEntry(entryPath, expectedName) {
|
|
let current = dirname(entryPath);
|
|
while (current !== dirname(current)) {
|
|
const manifestPath = join(current, "package.json");
|
|
if (existsSync(manifestPath) && json(manifestPath).name === expectedName) return current;
|
|
current = dirname(current);
|
|
}
|
|
throw new Error(`Cannot resolve package root for ${expectedName}.`);
|
|
}
|
|
|
|
function copyRuntimeDependencies(sourceRoot, destinationRoot, rootNames) {
|
|
debug(`resolve dependencies for ${relative(repositoryRoot, sourceRoot)}`);
|
|
const records = new Map();
|
|
function copyResolved(name, requireFrom, destinationNodeModules, ancestors) {
|
|
let entry;
|
|
try {
|
|
entry = requireFrom.resolve(name);
|
|
} catch (error) {
|
|
throw new Error(`Runtime dependency ${name} is unavailable from ${sourceRoot}.`, { cause: error });
|
|
}
|
|
const root = packageRootFromEntry(entry, name);
|
|
const manifest = json(join(root, "package.json"));
|
|
const identity = `${manifest.name}@${manifest.version}`;
|
|
const destination = join(destinationNodeModules, ...name.split("/"));
|
|
debug(`copy dependency ${identity}`);
|
|
mkdirSync(dirname(destination), { recursive: true });
|
|
copyTree(root, destination, (source) => {
|
|
const name = basename(source);
|
|
return name !== "node_modules" && !developmentOnlyDependencyDirectories.has(name);
|
|
});
|
|
records.set(identity, { name: manifest.name, version: manifest.version });
|
|
if (ancestors.has(identity)) return;
|
|
const nestedAncestors = new Set(ancestors).add(identity);
|
|
const nestedRequire = createRequire(join(root, "package.json"));
|
|
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
|
copyResolved(dependency, nestedRequire, join(destination, "node_modules"), nestedAncestors);
|
|
}
|
|
}
|
|
const rootRequire = createRequire(join(sourceRoot, "package.json"));
|
|
for (const name of rootNames) copyResolved(name, rootRequire, join(destinationRoot, "node_modules"), new Set());
|
|
return [...records.values()]
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
}
|
|
|
|
function listFiles(root) {
|
|
const files = [];
|
|
const reparsePoints = [];
|
|
function walk(current) {
|
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
const path = join(current, entry.name);
|
|
const attributes = lstatSync(path);
|
|
if (attributes.isSymbolicLink()) reparsePoints.push(relative(root, path).replaceAll("\\", "/"));
|
|
else if (entry.isDirectory()) walk(path);
|
|
else if (entry.isFile()) files.push(path);
|
|
}
|
|
}
|
|
walk(root);
|
|
return { files, reparsePoints };
|
|
}
|
|
|
|
function fileSha256(path) {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
|
}
|
|
|
|
function copyApplication(source, destination, runtimeDependencies) {
|
|
mkdirSync(destination, { recursive: true });
|
|
copyTree(join(source, "dist"), join(destination, "dist"));
|
|
writeJson(join(destination, "package.json"), { private: true, type: "module" });
|
|
return copyRuntimeDependencies(source, destination, runtimeDependencies);
|
|
}
|
|
|
|
function buildArtifacts(stagingRoot) {
|
|
debug("build workspace artifacts");
|
|
run("pnpm", ["--filter", "@dada/shared-contracts", "build"]);
|
|
run("pnpm", ["--filter", "@dada/web", "build"]);
|
|
run("pnpm", ["--filter", "@dada/api", "build"]);
|
|
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
|
run("dotnet", ["restore", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configfile", "NuGet.Config"]);
|
|
run("dotnet", [
|
|
"publish",
|
|
"supervisor/Dada.Supervisor/Dada.Supervisor.csproj",
|
|
"--configuration", "Release",
|
|
"--no-restore",
|
|
"--output", stagingRoot,
|
|
"-p:DebugType=None",
|
|
"-p:DebugSymbols=false",
|
|
]);
|
|
}
|
|
|
|
function createZip(packageDirectory, zipPath) {
|
|
const escapedPackage = packageDirectory.replaceAll("'", "''");
|
|
const escapedZip = zipPath.replaceAll("'", "''");
|
|
run("powershell.exe", [
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
`Compress-Archive -LiteralPath '${escapedPackage}' -DestinationPath '${escapedZip}' -CompressionLevel Optimal`,
|
|
]);
|
|
}
|
|
|
|
async function waitForHealth(child) {
|
|
const deadline = Date.now() + 15_000;
|
|
let lastError;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`Packaged API exited early with code ${child.exitCode}.`);
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${fixedPort}/healthz`, { headers: { Host: `127.0.0.1:${fixedPort}` } });
|
|
if (response.ok) return response.json();
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
|
}
|
|
throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError });
|
|
}
|
|
|
|
async function verifyExtractedPackage(zipPath, packageName) {
|
|
const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-"));
|
|
try {
|
|
const escapedZip = zipPath.replaceAll("'", "''");
|
|
const escapedExtract = extractRoot.replaceAll("'", "''");
|
|
run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Expand-Archive -LiteralPath '${escapedZip}' -DestinationPath '${escapedExtract}'`]);
|
|
const packageRoot = join(extractRoot, packageName);
|
|
const nativeResult = spawnSync(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "native-smoke.cjs")], {
|
|
cwd: packageRoot,
|
|
encoding: "utf8",
|
|
});
|
|
if (nativeResult.status !== 0) throw new Error(`Packaged native module failed:\n${nativeResult.stderr ?? ""}`);
|
|
const native = JSON.parse(nativeResult.stdout.trim());
|
|
const supervisorProbe = spawnSync(join(packageRoot, "Dada.exe"), ["--package-layout-probe"], {
|
|
cwd: packageRoot,
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
if (supervisorProbe.status !== 2) throw new Error(`Packaged Dada.exe usage probe exited with ${supervisorProbe.status}.`);
|
|
const api = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs")], {
|
|
cwd: packageRoot,
|
|
env: { ...process.env, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate") },
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
});
|
|
try {
|
|
const health = await waitForHealth(api);
|
|
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
|
|
body: JSON.stringify({
|
|
brands: [{ brand: "Google Chrome", version: "150" }],
|
|
full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }],
|
|
platform: "Windows",
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"sec-ch-ua": '"Google Chrome";v="150"',
|
|
"sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"',
|
|
"sec-ch-ua-platform": '"Windows"',
|
|
},
|
|
method: "POST",
|
|
});
|
|
if (releaseGate.status !== 426) throw new Error(`Candidate RELEASE.json unexpectedly passed with ${releaseGate.status}.`);
|
|
return {
|
|
api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" },
|
|
native,
|
|
supervisor: { credential_store_access: false, executable: "Dada.exe", exit_code: supervisorProbe.status, status: "passed" },
|
|
};
|
|
} finally {
|
|
if (api.exitCode === null) {
|
|
const exited = new Promise((resolveExit) => api.once("exit", resolveExit));
|
|
api.kill();
|
|
await exited;
|
|
}
|
|
}
|
|
} finally {
|
|
removeTree(extractRoot);
|
|
}
|
|
}
|
|
|
|
function scanPackage(packageDirectory) {
|
|
const { files, reparsePoints } = listFiles(packageDirectory);
|
|
const disallowedMatches = [];
|
|
const repositoryPath = repositoryRoot.toLowerCase();
|
|
const windowsUserPath = `${process.env.SystemDrive ?? "C:"}\\Users\\`.toLowerCase();
|
|
const userName = (process.env.USERNAME ?? "").toLowerCase();
|
|
for (const path of files) {
|
|
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
if (!textExtensions.has(extension)) continue;
|
|
const content = readFileSync(path, "utf8").toLowerCase();
|
|
const relativePath = relative(packageDirectory, path).replaceAll("\\", "/");
|
|
if (content.includes(repositoryPath)) disallowedMatches.push({ path: relativePath, rule: "repository_absolute_path" });
|
|
if (content.includes(windowsUserPath)) disallowedMatches.push({ path: relativePath, rule: "windows_user_path" });
|
|
if (userName.length >= 3 && content.includes(userName)) disallowedMatches.push({ path: relativePath, rule: "windows_username" });
|
|
if (/\b(?:sk|key)-[a-z0-9_-]{16,}\b/i.test(content)) disallowedMatches.push({ path: relativePath, rule: "credential_shape" });
|
|
if (!relativePath.includes("/node_modules/") && !relativePath.startsWith("LICENSES/")) {
|
|
for (const email of content.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) ?? []) {
|
|
if (!email.endsWith(".invalid")) disallowedMatches.push({ path: relativePath, rule: "non_fixture_email" });
|
|
}
|
|
}
|
|
}
|
|
return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" };
|
|
}
|
|
|
|
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }) {
|
|
if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) {
|
|
throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64.");
|
|
}
|
|
const resolvedOutput = ensureBuildOutput(outputRoot);
|
|
removeTree(resolvedOutput);
|
|
mkdirSync(resolvedOutput, { recursive: true });
|
|
const packageManifest = json(join(repositoryRoot, "package.json"));
|
|
const appVersion = packageManifest.version;
|
|
const packageName = `Dada-P0A-${appVersion}-win-x64`;
|
|
const packageDirectory = join(resolvedOutput, packageName);
|
|
const supervisorPublish = join(resolvedOutput, "supervisor-publish");
|
|
buildArtifacts(supervisorPublish);
|
|
debug("assemble package layout");
|
|
mkdirSync(packageDirectory, { recursive: true });
|
|
|
|
const supervisorExecutable = join(supervisorPublish, "Dada.Supervisor.exe");
|
|
if (!existsSync(supervisorExecutable)) throw new Error("Supervisor publish did not produce Dada.Supervisor.exe.");
|
|
copyFileSync(supervisorExecutable, join(packageDirectory, "Dada.exe"));
|
|
for (const name of ["Dada.Supervisor.deps.json", "Dada.Supervisor.dll", "Dada.Supervisor.runtimeconfig.json"]) {
|
|
copyFileSync(join(supervisorPublish, name), join(packageDirectory, name));
|
|
}
|
|
mkdirSync(join(packageDirectory, "runtime"), { recursive: true });
|
|
copyFileSync(process.execPath, join(packageDirectory, "runtime", "node.exe"));
|
|
|
|
const serverRoot = join(packageDirectory, "server");
|
|
debug("copy API application");
|
|
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify"]);
|
|
debug("copy Worker application");
|
|
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
|
|
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
|
mkdirSync(sharedDestination, { recursive: true });
|
|
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
|
copyFileSync(join(repositoryRoot, "packages", "shared-contracts", "package.json"), join(sharedDestination, "package.json"));
|
|
writeFileSync(join(serverRoot, "api.mjs"), 'import "./api/dist/main.js";\n');
|
|
writeFileSync(join(serverRoot, "worker.mjs"), 'import "./worker/dist/worker.js";\n');
|
|
|
|
const nativeSource = join(repositoryRoot, "apps", "api", "node_modules", "better-sqlite3", "prebuilds", "win32-x64.node");
|
|
mkdirSync(join(serverRoot, "native"), { recursive: true });
|
|
copyFileSync(nativeSource, join(serverRoot, "native", "better_sqlite3.node"));
|
|
writeFileSync(join(serverRoot, "native-smoke.cjs"), [
|
|
'"use strict";',
|
|
'const { createRequire } = require("node:module");',
|
|
'const { join } = require("node:path");',
|
|
'const requireApi = createRequire(join(__dirname, "api", "package.json"));',
|
|
'const Database = requireApi("better-sqlite3");',
|
|
'const database = new Database(":memory:", { nativeBinding: join(__dirname, "native", "better_sqlite3.node") });',
|
|
'const row = database.prepare("select 1 as ok").get();',
|
|
'database.close();',
|
|
'process.stdout.write(JSON.stringify({ status: row.ok === 1 ? "passed" : "failed" }));',
|
|
'',
|
|
].join("\n"));
|
|
|
|
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
|
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
|
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
|
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
|
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
|
|
|
const commit = run("git", ["rev-parse", "HEAD"]);
|
|
writeJson(join(packageDirectory, "RELEASE.json"), {
|
|
app_version: appVersion,
|
|
browsers: [],
|
|
build_commit: commit,
|
|
release_status: "candidate_unvalidated",
|
|
schema_version: "0",
|
|
windows_build: null,
|
|
});
|
|
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
|
|
"Dada P0-A candidate package",
|
|
"",
|
|
"This candidate is unsigned and is not a final P0-A release.",
|
|
"Verify the adjacent SHA-256 file before first launch.",
|
|
"Windows SmartScreen may warn on first launch because the executable is unsigned.",
|
|
"For an antivirus alert, compare the package hash with the Gitea build record.",
|
|
"Do not disable antivirus protection, add broad exclusions, or skip hash verification.",
|
|
"To update, exit Dada from the tray and replace the complete program directory.",
|
|
"Dada uses 127.0.0.1:43121 and does not support LAN or remote access.",
|
|
"A final RELEASE.json is created only after WP-7 acceptance.",
|
|
"",
|
|
].join("\r\n"));
|
|
|
|
removeTree(supervisorPublish);
|
|
const packageScan = scanPackage(packageDirectory);
|
|
if (packageScan.status !== "passed") throw new Error(`Portable package scan failed: ${JSON.stringify(packageScan.disallowed_matches)}`);
|
|
const zipPath = join(resolvedOutput, `${packageName}.zip`);
|
|
createZip(packageDirectory, zipPath);
|
|
const zipSha256 = fileSha256(zipPath);
|
|
const shaPath = `${zipPath}.sha256`;
|
|
writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`);
|
|
const processTree = await verifyExtractedPackage(zipPath, packageName);
|
|
const fileEntries = listFiles(packageDirectory).files.map((path) => ({
|
|
path: relative(packageDirectory, path).replaceAll("\\", "/"),
|
|
sha256: fileSha256(path),
|
|
size: statSync(path).size,
|
|
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
const finalManifest = {
|
|
app_version: appVersion,
|
|
files: fileEntries,
|
|
fixed_port: fixedPort,
|
|
package_name: packageName,
|
|
release_status: "candidate_unvalidated",
|
|
schema_version: "1.0",
|
|
zip_sha256: zipSha256,
|
|
};
|
|
if (evidenceDirectory) {
|
|
mkdirSync(evidenceDirectory, { recursive: true });
|
|
writeJson(join(evidenceDirectory, "package-manifest.json"), finalManifest);
|
|
writeFileSync(join(evidenceDirectory, "sha256.txt"), `${zipSha256} ${basename(zipPath)}\n`);
|
|
writeJson(join(evidenceDirectory, "package-scan.json"), packageScan);
|
|
writeJson(join(evidenceDirectory, "process-tree.json"), processTree);
|
|
}
|
|
return { packageManifest: finalManifest, packageScan, processTree, status: "passed" };
|
|
}
|