#!/usr/bin/env bun // scripts/package-source.ts // PB-26-212: ONE source-archive definition for standalone, Chrome+Firefox, // and Firefox-only release paths. Includes crypto-core, generates // SOURCE-MANIFEST.json, and produces deterministic (reproducible) ZIPs. import { createHash } from "node:crypto"; import { crc32, deflateRawSync } from "node:zlib"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs"; import { basename, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_DIR = resolve(dirname_safe()); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); function dirname_safe(): string { try { return fileURLToPath(new URL(".", import.meta.url)); } catch { return __dirname; } } // ─── Exclusions ───────────────────────────────────────────────────────────── const EXCLUDE_PATTERNS = [ /node_modules\//, /dist-[^/]+\//, /\.zip$/, /crypto-core\/target\//, /crypto-core\/generated\//, /\.DS_Store$/, /__MACOSX\//, ]; function shouldExclude(relPath: string): boolean { return EXCLUDE_PATTERNS.some((p) => p.test(relPath)); } // ─── File collection ──────────────────────────────────────────────────────── const INCLUDE_DIRS = ["src", "scripts", "public", "crypto-core"]; const INCLUDE_FILES = [ "fixtures/golden/peach-payload-v4.json", "fixtures/golden/sync-entry-envelope-v1.json", "manifest.json", "manifest.firefox.json", "package.json", "bun.lock", "tsconfig.json", "vite.config.ts", ]; function walkDir(dir: string, files: string[]): void { let entries: ReturnType; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = join(dir, entry.name); const relPath = relative(REPO_ROOT, fullPath); if (entry.isDirectory()) { if (shouldExclude(`${relPath}/`)) continue; walkDir(fullPath, files); } else if (entry.isFile()) { if (shouldExclude(relPath)) continue; files.push(relPath); } } } export function collectSourceFiles(): string[] { const files: string[] = []; for (const dir of INCLUDE_DIRS) { const absDir = join(REPO_ROOT, dir); if (existsSync(absDir)) { walkDir(absDir, files); } } for (const file of INCLUDE_FILES) { if (existsSync(join(REPO_ROOT, file))) { files.push(file); } } return [...new Set(files)].sort(); } // ─── SOURCE-MANIFEST.json ─────────────────────────────────────────────────── export interface SourceManifest { peachVersion: string; noiseWasmSha256: string; toolchain: { bun: string; node: string; rust: string; }; files: Array<{ path: string; sha256: string }>; } function sha256(data: Buffer | string): string { return createHash("sha256").update(data).digest("hex"); } function getNoiseWasmSha256(): string { const wasmPath = join( REPO_ROOT, "src/shared/noise-core-wasm/peach_noise_core_bg.wasm", ); return sha256(readFileSync(wasmPath)); } function readToolchain(): { bun: string; node: string; rust: string } { const pkg = JSON.parse( readFileSync(join(REPO_ROOT, "package.json"), "utf8"), ); const bunVersion = (pkg.packageManager ?? "").replace("bun@", ""); const cargoToml = readFileSync( join(REPO_ROOT, "crypto-core/Cargo.toml"), "utf8", ); const rustMatch = cargoToml.match(/rust-version\s*=\s*"([^"]+)"/); const rustVersion = rustMatch ? rustMatch[1] : "unknown"; return { bun: bunVersion, node: pkg.engines?.node ?? "unknown", rust: rustVersion, }; } export function buildSourceManifest( files: string[], version: string, ): SourceManifest { const entries = files.map((path) => ({ path, sha256: sha256(readFileSync(join(REPO_ROOT, path))), })); return { peachVersion: version, noiseWasmSha256: getNoiseWasmSha256(), toolchain: readToolchain(), files: entries, }; } // ─── Deterministic ZIP writer ─────────────────────────────────────────────── // Fixed DOS timestamp (1980-01-01 00:00:00) for reproducibility. const FIXED_DOS_TIME = 0x0000; const FIXED_DOS_DATE = 0x0021; interface ZipEntry { path: string; data: Buffer; } export function createDeterministicZip(entries: ZipEntry[]): Buffer { const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path), ); const localParts: Buffer[] = []; const centralParts: Buffer[] = []; let offset = 0; for (const entry of sorted) { const filename = Buffer.from(entry.path, "utf8"); const compressed = deflateRawSync(entry.data, { level: 9 }); const crc = crc32(entry.data) >>> 0; const compSize = compressed.length; const uncompSize = entry.data.length; // Local file header const lh = Buffer.alloc(30 + filename.length); lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6); lh.writeUInt16LE(8, 8); lh.writeUInt16LE(FIXED_DOS_TIME, 10); lh.writeUInt16LE(FIXED_DOS_DATE, 12); lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(compSize, 18); lh.writeUInt32LE(uncompSize, 22); lh.writeUInt16LE(filename.length, 26); lh.writeUInt16LE(0, 28); filename.copy(lh, 30); localParts.push(lh, compressed); // Central directory record const cd = Buffer.alloc(46 + filename.length); cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(FIXED_DOS_TIME, 12); cd.writeUInt16LE(FIXED_DOS_DATE, 14); cd.writeUInt32LE(crc, 16); cd.writeUInt32LE(compSize, 20); cd.writeUInt32LE(uncompSize, 24); cd.writeUInt16LE(filename.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); cd.writeUInt32LE(offset, 42); filename.copy(cd, 46); centralParts.push(cd); offset += lh.length + compressed.length; } const centralDir = Buffer.concat(centralParts); const centralOffset = offset; const centralSize = centralDir.length; // End of central directory record const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); eocd.writeUInt16LE(sorted.length, 8); eocd.writeUInt16LE(sorted.length, 10); eocd.writeUInt32LE(centralSize, 12); eocd.writeUInt32LE(centralOffset, 16); eocd.writeUInt16LE(0, 20); return Buffer.concat([...localParts, centralDir, eocd]); } // ─── Package source archive ───────────────────────────────────────────────── function readVersion(manifestPath: string): string { const manifest = JSON.parse( readFileSync(join(REPO_ROOT, manifestPath), "utf8"), ); if (!manifest.version) { throw new Error(`Could not read version from ${manifestPath}`); } return manifest.version; } function ensureArtifactsDir(): string { const dir = join(REPO_ROOT, "artifacts"); if (!existsSync(dir)) mkdirSync(dir); return dir; } export function packageSourceArchive(version: string): string { const artifactsDir = ensureArtifactsDir(); const archivePath = join(artifactsDir, `peach-source-${version}.zip`); const files = collectSourceFiles(); const manifest = buildSourceManifest(files, version); const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`; const entries: ZipEntry[] = files.map((path) => ({ path, data: readFileSync(join(REPO_ROOT, path)), })); entries.push({ path: "SOURCE-MANIFEST.json", data: Buffer.from(manifestJson, "utf8"), }); const zipBuffer = createDeterministicZip(entries); writeFileSync(archivePath, zipBuffer); console.log( `Packaged source archive → ${basename(archivePath)} (${files.length} files + SOURCE-MANIFEST.json)`, ); return archivePath; } // ─── Standalone execution ─────────────────────────────────────────────────── if (import.meta.main) { const version = readVersion("manifest.json"); const archivePath = packageSourceArchive(version); console.log(`\nSource archive: ${archivePath}`); }