package-source.ts

8.5 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  "manifest.json",51  "manifest.firefox.json",52  "package.json",53  "bun.lock",54  "tsconfig.json",55  "vite.config.ts",56];5758function walkDir(dir: string, files: string[]): void {59  let entries: ReturnType<typeof readdirSync>;60  try {61    entries = readdirSync(dir, { withFileTypes: true });62  } catch {63    return;64  }65  for (const entry of entries) {66    const fullPath = join(dir, entry.name);67    const relPath = relative(REPO_ROOT, fullPath);68    if (entry.isDirectory()) {69      if (shouldExclude(`${relPath}/`)) continue;70      walkDir(fullPath, files);71    } else if (entry.isFile()) {72      if (shouldExclude(relPath)) continue;73      files.push(relPath);74    }75  }76}7778export function collectSourceFiles(): string[] {79  const files: string[] = [];80  for (const dir of INCLUDE_DIRS) {81    const absDir = join(REPO_ROOT, dir);82    if (existsSync(absDir)) {83      walkDir(absDir, files);84    }85  }86  for (const file of INCLUDE_FILES) {87    if (existsSync(join(REPO_ROOT, file))) {88      files.push(file);89    }90  }91  return [...new Set(files)].sort();92}9394// ─── SOURCE-MANIFEST.json ───────────────────────────────────────────────────9596export interface SourceManifest {97  peachVersion: string;98  noiseWasmSha256: string;99  toolchain: {100    bun: string;101    node: string;102    rust: string;103  };104  files: Array<{ path: string; sha256: string }>;105}106107function sha256(data: Buffer | string): string {108  return createHash("sha256").update(data).digest("hex");109}110111function getNoiseWasmSha256(): string {112  const wasmPath = join(113    REPO_ROOT,114    "src/shared/noise-core-wasm/peach_noise_core_bg.wasm",115  );116  return sha256(readFileSync(wasmPath));117}118119function readToolchain(): { bun: string; node: string; rust: string } {120  const pkg = JSON.parse(121    readFileSync(join(REPO_ROOT, "package.json"), "utf8"),122  );123  const bunVersion = (pkg.packageManager ?? "").replace("bun@", "");124125  const cargoToml = readFileSync(126    join(REPO_ROOT, "crypto-core/Cargo.toml"),127    "utf8",128  );129  const rustMatch = cargoToml.match(/rust-version\s*=\s*"([^"]+)"/);130  const rustVersion = rustMatch ? rustMatch[1] : "unknown";131132  return {133    bun: bunVersion,134    node: pkg.engines?.node ?? "unknown",135    rust: rustVersion,136  };137}138139export function buildSourceManifest(140  files: string[],141  version: string,142): SourceManifest {143  const entries = files.map((path) => ({144    path,145    sha256: sha256(readFileSync(join(REPO_ROOT, path))),146  }));147  return {148    peachVersion: version,149    noiseWasmSha256: getNoiseWasmSha256(),150    toolchain: readToolchain(),151    files: entries,152  };153}154155// ─── Deterministic ZIP writer ───────────────────────────────────────────────156// Fixed DOS timestamp (1980-01-01 00:00:00) for reproducibility.157158const FIXED_DOS_TIME = 0x0000;159const FIXED_DOS_DATE = 0x0021;160161interface ZipEntry {162  path: string;163  data: Buffer;164}165166export function createDeterministicZip(entries: ZipEntry[]): Buffer {167  const sorted = [...entries].sort((a, b) =>168    a.path.localeCompare(b.path),169  );170  const localParts: Buffer[] = [];171  const centralParts: Buffer[] = [];172  let offset = 0;173174  for (const entry of sorted) {175    const filename = Buffer.from(entry.path, "utf8");176    const compressed = deflateRawSync(entry.data, { level: 9 });177    const crc = crc32(entry.data) >>> 0;178    const compSize = compressed.length;179    const uncompSize = entry.data.length;180181    // Local file header182    const lh = Buffer.alloc(30 + filename.length);183    lh.writeUInt32LE(0x04034b50, 0);184    lh.writeUInt16LE(20, 4);185    lh.writeUInt16LE(0, 6);186    lh.writeUInt16LE(8, 8);187    lh.writeUInt16LE(FIXED_DOS_TIME, 10);188    lh.writeUInt16LE(FIXED_DOS_DATE, 12);189    lh.writeUInt32LE(crc, 14);190    lh.writeUInt32LE(compSize, 18);191    lh.writeUInt32LE(uncompSize, 22);192    lh.writeUInt16LE(filename.length, 26);193    lh.writeUInt16LE(0, 28);194    filename.copy(lh, 30);195196    localParts.push(lh, compressed);197198    // Central directory record199    const cd = Buffer.alloc(46 + filename.length);200    cd.writeUInt32LE(0x02014b50, 0);201    cd.writeUInt16LE(20, 4);202    cd.writeUInt16LE(20, 6);203    cd.writeUInt16LE(0, 8);204    cd.writeUInt16LE(8, 10);205    cd.writeUInt16LE(FIXED_DOS_TIME, 12);206    cd.writeUInt16LE(FIXED_DOS_DATE, 14);207    cd.writeUInt32LE(crc, 16);208    cd.writeUInt32LE(compSize, 20);209    cd.writeUInt32LE(uncompSize, 24);210    cd.writeUInt16LE(filename.length, 28);211    cd.writeUInt16LE(0, 30);212    cd.writeUInt16LE(0, 32);213    cd.writeUInt16LE(0, 34);214    cd.writeUInt16LE(0, 36);215    cd.writeUInt32LE(0, 38);216    cd.writeUInt32LE(offset, 42);217    filename.copy(cd, 46);218219    centralParts.push(cd);220    offset += lh.length + compressed.length;221  }222223  const centralDir = Buffer.concat(centralParts);224  const centralOffset = offset;225  const centralSize = centralDir.length;226227  // End of central directory record228  const eocd = Buffer.alloc(22);229  eocd.writeUInt32LE(0x06054b50, 0);230  eocd.writeUInt16LE(0, 4);231  eocd.writeUInt16LE(0, 6);232  eocd.writeUInt16LE(sorted.length, 8);233  eocd.writeUInt16LE(sorted.length, 10);234  eocd.writeUInt32LE(centralSize, 12);235  eocd.writeUInt32LE(centralOffset, 16);236  eocd.writeUInt16LE(0, 20);237238  return Buffer.concat([...localParts, centralDir, eocd]);239}240241// ─── Package source archive ─────────────────────────────────────────────────242243function readVersion(manifestPath: string): string {244  const manifest = JSON.parse(245    readFileSync(join(REPO_ROOT, manifestPath), "utf8"),246  );247  if (!manifest.version) {248    throw new Error(`Could not read version from ${manifestPath}`);249  }250  return manifest.version;251}252253function ensureArtifactsDir(): string {254  const dir = join(REPO_ROOT, "artifacts");255  if (!existsSync(dir)) mkdirSync(dir);256  return dir;257}258259export function packageSourceArchive(version: string): string {260  const artifactsDir = ensureArtifactsDir();261  const archivePath = join(artifactsDir, `peach-source-${version}.zip`);262263  const files = collectSourceFiles();264  const manifest = buildSourceManifest(files, version);265  const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;266267  const entries: ZipEntry[] = files.map((path) => ({268    path,269    data: readFileSync(join(REPO_ROOT, path)),270  }));271  entries.push({272    path: "SOURCE-MANIFEST.json",273    data: Buffer.from(manifestJson, "utf8"),274  });275276  const zipBuffer = createDeterministicZip(entries);277  writeFileSync(archivePath, zipBuffer);278279  console.log(280    `Packaged source archive → ${basename(archivePath)} (${files.length} files + SOURCE-MANIFEST.json)`,281  );282  return archivePath;283}284285// ─── Standalone execution ───────────────────────────────────────────────────286287if (import.meta.main) {288  const version = readVersion("manifest.json");289  const archivePath = packageSourceArchive(version);290  console.log(`\nSource archive: ${archivePath}`);291}