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  "fixtures/golden/sync-entry-envelope-v1.json",52  "manifest.json",53  "manifest.firefox.json",54  "package.json",55  "bun.lock",56  "tsconfig.json",57  "vite.config.ts",58];5960function walkDir(dir: string, files: string[]): void {61  let entries: ReturnType<typeof readdirSync>;62  try {63    entries = readdirSync(dir, { withFileTypes: true });64  } catch {65    return;66  }67  for (const entry of entries) {68    const fullPath = join(dir, entry.name);69    const relPath = relative(REPO_ROOT, fullPath);70    if (entry.isDirectory()) {71      if (shouldExclude(`${relPath}/`)) continue;72      walkDir(fullPath, files);73    } else if (entry.isFile()) {74      if (shouldExclude(relPath)) continue;75      files.push(relPath);76    }77  }78}7980export function collectSourceFiles(): string[] {81  const files: string[] = [];82  for (const dir of INCLUDE_DIRS) {83    const absDir = join(REPO_ROOT, dir);84    if (existsSync(absDir)) {85      walkDir(absDir, files);86    }87  }88  for (const file of INCLUDE_FILES) {89    if (existsSync(join(REPO_ROOT, file))) {90      files.push(file);91    }92  }93  return [...new Set(files)].sort();94}9596// ─── SOURCE-MANIFEST.json ───────────────────────────────────────────────────9798export interface SourceManifest {99  peachVersion: string;100  noiseWasmSha256: string;101  toolchain: {102    bun: string;103    node: string;104    rust: string;105  };106  files: Array<{ path: string; sha256: string }>;107}108109function sha256(data: Buffer | string): string {110  return createHash("sha256").update(data).digest("hex");111}112113function getNoiseWasmSha256(): string {114  const wasmPath = join(115    REPO_ROOT,116    "src/shared/noise-core-wasm/peach_noise_core_bg.wasm",117  );118  return sha256(readFileSync(wasmPath));119}120121function readToolchain(): { bun: string; node: string; rust: string } {122  const pkg = JSON.parse(123    readFileSync(join(REPO_ROOT, "package.json"), "utf8"),124  );125  const bunVersion = (pkg.packageManager ?? "").replace("bun@", "");126127  const cargoToml = readFileSync(128    join(REPO_ROOT, "crypto-core/Cargo.toml"),129    "utf8",130  );131  const rustMatch = cargoToml.match(/rust-version\s*=\s*"([^"]+)"/);132  const rustVersion = rustMatch ? rustMatch[1] : "unknown";133134  return {135    bun: bunVersion,136    node: pkg.engines?.node ?? "unknown",137    rust: rustVersion,138  };139}140141export function buildSourceManifest(142  files: string[],143  version: string,144): SourceManifest {145  const entries = files.map((path) => ({146    path,147    sha256: sha256(readFileSync(join(REPO_ROOT, path))),148  }));149  return {150    peachVersion: version,151    noiseWasmSha256: getNoiseWasmSha256(),152    toolchain: readToolchain(),153    files: entries,154  };155}156157// ─── Deterministic ZIP writer ───────────────────────────────────────────────158// Fixed DOS timestamp (1980-01-01 00:00:00) for reproducibility.159160const FIXED_DOS_TIME = 0x0000;161const FIXED_DOS_DATE = 0x0021;162163interface ZipEntry {164  path: string;165  data: Buffer;166}167168export function createDeterministicZip(entries: ZipEntry[]): Buffer {169  const sorted = [...entries].sort((a, b) =>170    a.path.localeCompare(b.path),171  );172  const localParts: Buffer[] = [];173  const centralParts: Buffer[] = [];174  let offset = 0;175176  for (const entry of sorted) {177    const filename = Buffer.from(entry.path, "utf8");178    const compressed = deflateRawSync(entry.data, { level: 9 });179    const crc = crc32(entry.data) >>> 0;180    const compSize = compressed.length;181    const uncompSize = entry.data.length;182183    // Local file header184    const lh = Buffer.alloc(30 + filename.length);185    lh.writeUInt32LE(0x04034b50, 0);186    lh.writeUInt16LE(20, 4);187    lh.writeUInt16LE(0, 6);188    lh.writeUInt16LE(8, 8);189    lh.writeUInt16LE(FIXED_DOS_TIME, 10);190    lh.writeUInt16LE(FIXED_DOS_DATE, 12);191    lh.writeUInt32LE(crc, 14);192    lh.writeUInt32LE(compSize, 18);193    lh.writeUInt32LE(uncompSize, 22);194    lh.writeUInt16LE(filename.length, 26);195    lh.writeUInt16LE(0, 28);196    filename.copy(lh, 30);197198    localParts.push(lh, compressed);199200    // Central directory record201    const cd = Buffer.alloc(46 + filename.length);202    cd.writeUInt32LE(0x02014b50, 0);203    cd.writeUInt16LE(20, 4);204    cd.writeUInt16LE(20, 6);205    cd.writeUInt16LE(0, 8);206    cd.writeUInt16LE(8, 10);207    cd.writeUInt16LE(FIXED_DOS_TIME, 12);208    cd.writeUInt16LE(FIXED_DOS_DATE, 14);209    cd.writeUInt32LE(crc, 16);210    cd.writeUInt32LE(compSize, 20);211    cd.writeUInt32LE(uncompSize, 24);212    cd.writeUInt16LE(filename.length, 28);213    cd.writeUInt16LE(0, 30);214    cd.writeUInt16LE(0, 32);215    cd.writeUInt16LE(0, 34);216    cd.writeUInt16LE(0, 36);217    cd.writeUInt32LE(0, 38);218    cd.writeUInt32LE(offset, 42);219    filename.copy(cd, 46);220221    centralParts.push(cd);222    offset += lh.length + compressed.length;223  }224225  const centralDir = Buffer.concat(centralParts);226  const centralOffset = offset;227  const centralSize = centralDir.length;228229  // End of central directory record230  const eocd = Buffer.alloc(22);231  eocd.writeUInt32LE(0x06054b50, 0);232  eocd.writeUInt16LE(0, 4);233  eocd.writeUInt16LE(0, 6);234  eocd.writeUInt16LE(sorted.length, 8);235  eocd.writeUInt16LE(sorted.length, 10);236  eocd.writeUInt32LE(centralSize, 12);237  eocd.writeUInt32LE(centralOffset, 16);238  eocd.writeUInt16LE(0, 20);239240  return Buffer.concat([...localParts, centralDir, eocd]);241}242243// ─── Package source archive ─────────────────────────────────────────────────244245function readVersion(manifestPath: string): string {246  const manifest = JSON.parse(247    readFileSync(join(REPO_ROOT, manifestPath), "utf8"),248  );249  if (!manifest.version) {250    throw new Error(`Could not read version from ${manifestPath}`);251  }252  return manifest.version;253}254255function ensureArtifactsDir(): string {256  const dir = join(REPO_ROOT, "artifacts");257  if (!existsSync(dir)) mkdirSync(dir);258  return dir;259}260261export function packageSourceArchive(version: string): string {262  const artifactsDir = ensureArtifactsDir();263  const archivePath = join(artifactsDir, `peach-source-${version}.zip`);264265  const files = collectSourceFiles();266  const manifest = buildSourceManifest(files, version);267  const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;268269  const entries: ZipEntry[] = files.map((path) => ({270    path,271    data: readFileSync(join(REPO_ROOT, path)),272  }));273  entries.push({274    path: "SOURCE-MANIFEST.json",275    data: Buffer.from(manifestJson, "utf8"),276  });277278  const zipBuffer = createDeterministicZip(entries);279  writeFileSync(archivePath, zipBuffer);280281  console.log(282    `Packaged source archive → ${basename(archivePath)} (${files.length} files + SOURCE-MANIFEST.json)`,283  );284  return archivePath;285}286287// ─── Standalone execution ───────────────────────────────────────────────────288289if (import.meta.main) {290  const version = readVersion("manifest.json");291  const archivePath = packageSourceArchive(version);292  console.log(`\nSource archive: ${archivePath}`);293}