package-source.ts

8.6 KB · raw

1#!/usr/bin/env bun2// scripts/package-source.ts3// PB-26-212: ONE source-archive definition for standalone, Chrome+Firefox,4// and Firefox-only release paths. Includes crypto-core, generates5// SOURCE-MANIFEST.json, and produces deterministic (reproducible) ZIPs.67import { createHash } from "node:crypto";8import { crc32, deflateRawSync } from "node:zlib";9import {10  existsSync,11  mkdirSync,12  readFileSync,13  readdirSync,14  writeFileSync,15} from "node:fs";16import { basename, join, relative, resolve } from "node:path";17import { fileURLToPath } from "node:url";1819const SCRIPT_DIR = resolve(dirname_safe());20const REPO_ROOT = resolve(SCRIPT_DIR, "..");2122function dirname_safe(): string {23  try {24    return fileURLToPath(new URL(".", import.meta.url));25  } catch {26    return __dirname;27  }28}2930// ─── Exclusions ─────────────────────────────────────────────────────────────3132const EXCLUDE_PATTERNS = [33  /node_modules\//,34  /dist-[^/]+\//,35  /\.zip$/,36  /crypto-core\/target\//,37  /crypto-core\/generated\//,38  /\.DS_Store$/,39  /__MACOSX\//,40];4142function shouldExclude(relPath: string): boolean {43  return EXCLUDE_PATTERNS.some((p) => p.test(relPath));44}4546// ─── File collection ────────────────────────────────────────────────────────4748const INCLUDE_DIRS = ["src", "scripts", "public", "crypto-core"];49const INCLUDE_FILES = [50  "fixtures/golden/peach-payload-v4.json",51  "manifest.json",52  "manifest.firefox.json",53  "package.json",54  "bun.lock",55  "tsconfig.json",56  "vite.config.ts",57];5859function walkDir(dir: string, files: string[]): void {60  let entries: ReturnType<typeof readdirSync>;61  try {62    entries = readdirSync(dir, { withFileTypes: true });63  } catch {64    return;65  }66  for (const entry of entries) {67    const fullPath = join(dir, entry.name);68    const relPath = relative(REPO_ROOT, fullPath);69    if (entry.isDirectory()) {70      if (shouldExclude(`${relPath}/`)) continue;71      walkDir(fullPath, files);72    } else if (entry.isFile()) {73      if (shouldExclude(relPath)) continue;74      files.push(relPath);75    }76  }77}7879export function collectSourceFiles(): string[] {80  const files: string[] = [];81  for (const dir of INCLUDE_DIRS) {82    const absDir = join(REPO_ROOT, dir);83    if (existsSync(absDir)) {84      walkDir(absDir, files);85    }86  }87  for (const file of INCLUDE_FILES) {88    if (existsSync(join(REPO_ROOT, file))) {89      files.push(file);90    }91  }92  return [...new Set(files)].sort();93}9495// ─── SOURCE-MANIFEST.json ───────────────────────────────────────────────────9697export interface SourceManifest {98  peachVersion: string;99  noiseWasmSha256: string;100  toolchain: {101    bun: string;102    node: string;103    rust: string;104  };105  files: Array<{ path: string; sha256: string }>;106}107108function sha256(data: Buffer | string): string {109  return createHash("sha256").update(data).digest("hex");110}111112function getNoiseWasmSha256(): string {113  const wasmPath = join(114    REPO_ROOT,115    "src/shared/noise-core-wasm/peach_noise_core_bg.wasm",116  );117  return sha256(readFileSync(wasmPath));118}119120function readToolchain(): { bun: string; node: string; rust: string } {121  const pkg = JSON.parse(122    readFileSync(join(REPO_ROOT, "package.json"), "utf8"),123  );124  const bunVersion = (pkg.packageManager ?? "").replace("bun@", "");125126  const cargoToml = readFileSync(127    join(REPO_ROOT, "crypto-core/Cargo.toml"),128    "utf8",129  );130  const rustMatch = cargoToml.match(/rust-version\s*=\s*"([^"]+)"/);131  const rustVersion = rustMatch ? rustMatch[1] : "unknown";132133  return {134    bun: bunVersion,135    node: pkg.engines?.node ?? "unknown",136    rust: rustVersion,137  };138}139140export function buildSourceManifest(141  files: string[],142  version: string,143): SourceManifest {144  const entries = files.map((path) => ({145    path,146    sha256: sha256(readFileSync(join(REPO_ROOT, path))),147  }));148  return {149    peachVersion: version,150    noiseWasmSha256: getNoiseWasmSha256(),151    toolchain: readToolchain(),152    files: entries,153  };154}155156// ─── Deterministic ZIP writer ───────────────────────────────────────────────157// Fixed DOS timestamp (1980-01-01 00:00:00) for reproducibility.158159const FIXED_DOS_TIME = 0x0000;160const FIXED_DOS_DATE = 0x0021;161162interface ZipEntry {163  path: string;164  data: Buffer;165}166167export function createDeterministicZip(entries: ZipEntry[]): Buffer {168  const sorted = [...entries].sort((a, b) =>169    a.path.localeCompare(b.path),170  );171  const localParts: Buffer[] = [];172  const centralParts: Buffer[] = [];173  let offset = 0;174175  for (const entry of sorted) {176    const filename = Buffer.from(entry.path, "utf8");177    const compressed = deflateRawSync(entry.data, { level: 9 });178    const crc = crc32(entry.data) >>> 0;179    const compSize = compressed.length;180    const uncompSize = entry.data.length;181182    // Local file header183    const lh = Buffer.alloc(30 + filename.length);184    lh.writeUInt32LE(0x04034b50, 0);185    lh.writeUInt16LE(20, 4);186    lh.writeUInt16LE(0, 6);187    lh.writeUInt16LE(8, 8);188    lh.writeUInt16LE(FIXED_DOS_TIME, 10);189    lh.writeUInt16LE(FIXED_DOS_DATE, 12);190    lh.writeUInt32LE(crc, 14);191    lh.writeUInt32LE(compSize, 18);192    lh.writeUInt32LE(uncompSize, 22);193    lh.writeUInt16LE(filename.length, 26);194    lh.writeUInt16LE(0, 28);195    filename.copy(lh, 30);196197    localParts.push(lh, compressed);198199    // Central directory record200    const cd = Buffer.alloc(46 + filename.length);201    cd.writeUInt32LE(0x02014b50, 0);202    cd.writeUInt16LE(20, 4);203    cd.writeUInt16LE(20, 6);204    cd.writeUInt16LE(0, 8);205    cd.writeUInt16LE(8, 10);206    cd.writeUInt16LE(FIXED_DOS_TIME, 12);207    cd.writeUInt16LE(FIXED_DOS_DATE, 14);208    cd.writeUInt32LE(crc, 16);209    cd.writeUInt32LE(compSize, 20);210    cd.writeUInt32LE(uncompSize, 24);211    cd.writeUInt16LE(filename.length, 28);212    cd.writeUInt16LE(0, 30);213    cd.writeUInt16LE(0, 32);214    cd.writeUInt16LE(0, 34);215    cd.writeUInt16LE(0, 36);216    cd.writeUInt32LE(0, 38);217    cd.writeUInt32LE(offset, 42);218    filename.copy(cd, 46);219220    centralParts.push(cd);221    offset += lh.length + compressed.length;222  }223224  const centralDir = Buffer.concat(centralParts);225  const centralOffset = offset;226  const centralSize = centralDir.length;227228  // End of central directory record229  const eocd = Buffer.alloc(22);230  eocd.writeUInt32LE(0x06054b50, 0);231  eocd.writeUInt16LE(0, 4);232  eocd.writeUInt16LE(0, 6);233  eocd.writeUInt16LE(sorted.length, 8);234  eocd.writeUInt16LE(sorted.length, 10);235  eocd.writeUInt32LE(centralSize, 12);236  eocd.writeUInt32LE(centralOffset, 16);237  eocd.writeUInt16LE(0, 20);238239  return Buffer.concat([...localParts, centralDir, eocd]);240}241242// ─── Package source archive ─────────────────────────────────────────────────243244function readVersion(manifestPath: string): string {245  const manifest = JSON.parse(246    readFileSync(join(REPO_ROOT, manifestPath), "utf8"),247  );248  if (!manifest.version) {249    throw new Error(`Could not read version from ${manifestPath}`);250  }251  return manifest.version;252}253254function ensureArtifactsDir(): string {255  const dir = join(REPO_ROOT, "artifacts");256  if (!existsSync(dir)) mkdirSync(dir);257  return dir;258}259260export function packageSourceArchive(version: string): string {261  const artifactsDir = ensureArtifactsDir();262  const archivePath = join(artifactsDir, `peach-source-${version}.zip`);263264  const files = collectSourceFiles();265  const manifest = buildSourceManifest(files, version);266  const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;267268  const entries: ZipEntry[] = files.map((path) => ({269    path,270    data: readFileSync(join(REPO_ROOT, path)),271  }));272  entries.push({273    path: "SOURCE-MANIFEST.json",274    data: Buffer.from(manifestJson, "utf8"),275  });276277  const zipBuffer = createDeterministicZip(entries);278  writeFileSync(archivePath, zipBuffer);279280  console.log(281    `Packaged source archive → ${basename(archivePath)} (${files.length} files + SOURCE-MANIFEST.json)`,282  );283  return archivePath;284}285286// ─── Standalone execution ───────────────────────────────────────────────────287288if (import.meta.main) {289  const version = readVersion("manifest.json");290  const archivePath = packageSourceArchive(version);291  console.log(`\nSource archive: ${archivePath}`);292}