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 "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}95969798export 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}156157158159160const 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 184 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 201 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 230 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}242243244245function 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}286287288289if (import.meta.main) {290 const version = readVersion("manifest.json");291 const archivePath = packageSourceArchive(version);292 console.log(`\nSource archive: ${archivePath}`);293}