1234567import { 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}29303132const 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}45464748const 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}93949596export 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}154155156157158const 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 182 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 199 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 228 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}240241242243function 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}284285286287if (import.meta.main) {288 const version = readVersion("manifest.json");289 const archivePath = packageSourceArchive(version);290 console.log(`\nSource archive: ${archivePath}`);291}