Files
tyx_AI_xhs/scripts/lib/portable-package.mjs
T
suyx a22b1f19e9
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
fix(WP7-07): 修正最终包浏览器门禁探测
2026-08-04 23:15:36 +08:00

440 lines
20 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) {
try {
entry = requireFrom.resolve(`${name}/package`);
} catch {
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);
}
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
try {
copyResolved(dependency, nestedRequire, join(destination, "node_modules"), nestedAncestors);
} catch (error) {
if (error?.cause?.code !== "MODULE_NOT_FOUND") throw error;
debug(`skip unavailable optional dependency ${dependency}`);
}
}
}
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", ["build:workspace-packages"]);
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 });
}
export async function verifyExtractedPackage(zipPath, packageName, expectedSupport) {
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 brands = [
{ brand: "Not_A Brand", version: "99" },
{ brand: "Chromium", version: String(expectedSupport.major) },
{ brand: expectedSupport.brand, version: String(expectedSupport.major) },
];
const fullVersionList = [
{ brand: "Not_A Brand", version: "99.0.0.0" },
{ brand: "Chromium", version: expectedSupport.fullVersion },
{ brand: expectedSupport.brand, version: expectedSupport.fullVersion },
];
const serializeBrands = (values) => values.map(({ brand, version }) => `"${brand}";v="${version}"`).join(", ");
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
body: JSON.stringify({
brands,
full_version_list: fullVersionList,
platform: "Windows",
}),
headers: {
"content-type": "application/json",
host: `127.0.0.1:${fixedPort}`,
origin: `http://127.0.0.1:${fixedPort}`,
"sec-ch-ua": serializeBrands(brands),
"sec-ch-ua-full-version-list": serializeBrands(fullVersionList),
"sec-ch-ua-platform": '"Windows"',
},
method: "POST",
});
if (releaseGate.status !== expectedSupport.statusCode) {
const responseBody = await releaseGate.text();
throw new Error(`Packaged RELEASE.json support gate returned ${releaseGate.status}; expected ${expectedSupport.statusCode}: ${responseBody}`);
}
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, releaseRecord }) {
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/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
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"]);
const finalRelease = releaseRecord !== undefined;
writeJson(join(packageDirectory, "RELEASE.json"), releaseRecord ?? {
app_version: appVersion,
browsers: [],
build_commit: commit,
release_status: "candidate_unvalidated",
schema_version: "0",
windows_build: null,
});
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
finalRelease ? "Dada P0-A first-version portable package" : "Dada P0-A candidate package",
"",
finalRelease
? "This unsigned first-version package passed the local P0-A release gates recorded in RELEASE.json."
: "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.",
finalRelease
? "Resend and Amap real-provider validation remain explicitly deferred and are not recorded as passed."
: "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 supportBrowser = finalRelease ? releaseRecord.browsers[0] : undefined;
const processTree = await verifyExtractedPackage(zipPath, packageName, finalRelease ? {
brand: supportBrowser.brand,
fullVersion: supportBrowser.fullVersion,
major: Number.parseInt(supportBrowser.fullVersion.split(".")[0], 10),
statusCode: 200,
} : {
brand: "Google Chrome",
fullVersion: "150.0.0.0",
major: 150,
statusCode: 426,
});
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: finalRelease ? releaseRecord.releaseStatus : "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" };
}