import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { createReadStream, createWriteStream } from 'node:fs'; import { lstat, mkdir, mkdtemp, open, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { pipeline } from 'node:stream/promises'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { app } from 'electron'; import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager'; import { PublishRuntimeError, resolvePublishRuntime, runElectronNode, type PublishRuntimeContext, } from './publish-runtime'; import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server'; import { writeStoredZip } from './streaming-zip'; const require = createRequire(import.meta.url); const AdmZip = require('adm-zip') as typeof import('adm-zip'); const FIXED_ZIP_TIME = new Date(1980, 0, 1); const FILE_MODE = 0o100644; const MAX_OUTPUT_FILES = 2_000; const MAX_OUTPUT_BYTES = 50 * 1024 * 1024; const activeReleaseProjects = new Set(); export type ArtifactContract = { schema_version: 1; entry_path: 'index.html'; source_digest: string; built_archive_digest: string; artifact_digest: string; file_count: number; total_bytes: number; files: Array<{ path: string; size: number; sha256: string }>; security_profile: 'works-square-static-sandbox-v1'; toolchain: { client: 'makelore'; client_version: string; node: string; npm: string; vite: string }; }; export type PreparedRelease = { sourceArchive: { path: string; name: 'project.zip'; bytes: Buffer; summary: StaticProjectPackageSummary }; builtArchive: { path: string; name: 'built-project.zip'; bytes: Buffer }; contract: ArtifactContract; distRoot: string; staticArtifact: StaticArtifactSnapshot; dispose(): Promise; }; export type PreparedReleaseSummary = { sourceArchive: { path: string; name: 'project.zip'; summary: StaticProjectPackageSummary }; builtArchive: { path: string; name: 'built-project.zip' }; contract: ArtifactContract; dispose(): Promise; }; export class ProjectReleaseBuildError extends Error { constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_CANCELLED' | 'LOCAL_BUILD_OUTPUT_MISSING' | 'LOCAL_BUILD_BUSY') { super(code); this.name = 'ProjectReleaseBuildError'; } } const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex'); const archivePath = (value: string) => sep === '/' ? value : value.split(sep).join('/'); const comparePaths = (left: string, right: string) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) throw new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED'); } function safeArchivePath(name: string): string[] | null { if (!name || name.includes('\\') || name.startsWith('/') || name.includes('\0')) return null; const parts = name.split('/'); if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return null; if (parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return null; return parts; } const ZIP_EOCD_SIGNATURE = 0x06054b50; const ZIP_CENTRAL_SIGNATURE = 0x02014b50; const ZIP_LOCAL_SIGNATURE = 0x04034b50; const ZIP_STORE_METHOD = 0; const ZIP_MAX_COMMENT_BYTES = 0xffff; const MAX_SOURCE_ARCHIVE_BYTES = 200 * 1024 * 1024; type StoredZipCentralEntry = { name: string; parts: string[]; compressedSize: number; uncompressedSize: number; localOffset: number; }; async function readZipBytes( handle: Awaited>, position: number, length: number, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const result = Buffer.alloc(length); let offset = 0; while (offset < length) { throwIfAborted(signal); const read = await handle.read(result, offset, length - offset, position + offset); if (read.bytesRead <= 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); offset += read.bytesRead; } return result; } function findZipEndOfCentralDirectory(tail: Buffer): number { for (let offset = tail.length - 22; offset >= 0; offset -= 1) { if (tail.readUInt32LE(offset) === ZIP_EOCD_SIGNATURE) return offset; } return -1; } async function readStoredZipEntries( archive: string, signal?: AbortSignal, ): Promise<{ handle: Awaited>; size: number; entries: StoredZipCentralEntry[] }> { const handle = await open(archive, 'r'); try { const stats = await handle.stat(); if (!stats.isFile() || stats.size > MAX_SOURCE_ARCHIVE_BYTES) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } const tailLength = Math.min(stats.size, 22 + ZIP_MAX_COMMENT_BYTES); const tail = await readZipBytes(handle, stats.size - tailLength, tailLength, signal); const endOffset = findZipEndOfCentralDirectory(tail); if (endOffset < 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const entryCount = tail.readUInt16LE(endOffset + 10); const centralSize = tail.readUInt32LE(endOffset + 12); const centralOffset = tail.readUInt32LE(endOffset + 16); if ( tail.readUInt16LE(endOffset + 4) !== 0 || tail.readUInt16LE(endOffset + 6) !== 0 || entryCount === 0 || entryCount > MAX_OUTPUT_FILES || centralSize > stats.size || centralOffset > stats.size || centralOffset + centralSize > stats.size ) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } const entries: StoredZipCentralEntry[] = []; let cursor = centralOffset; const centralEnd = centralOffset + centralSize; for (let index = 0; index < entryCount; index += 1) { throwIfAborted(signal); if (cursor + 46 > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const header = await readZipBytes(handle, cursor, 46, signal); if (header.readUInt32LE(0) !== ZIP_CENTRAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const nameLength = header.readUInt16LE(28); const extraLength = header.readUInt16LE(30); const commentLength = header.readUInt16LE(32); const recordLength = 46 + nameLength + extraLength + commentLength; if (cursor + recordLength > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const name = (await readZipBytes(handle, cursor + 46, nameLength, signal)).toString('utf8'); const parts = safeArchivePath(name); const flags = header.readUInt16LE(8); const method = header.readUInt16LE(10); const compressedSize = header.readUInt32LE(20); const uncompressedSize = header.readUInt32LE(24); const localOffset = header.readUInt32LE(42); const unixMode = (header.readUInt32LE(38) >>> 16) & 0xffff; const unixType = unixMode & 0xf000; if ( !parts || (flags & 0x0001) !== 0 || method !== ZIP_STORE_METHOD || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset >= centralOffset || (unixType !== 0 && unixType !== 0x8000) ) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } entries.push({ name, parts, compressedSize, uncompressedSize, localOffset }); cursor += recordLength; } if (cursor !== centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); return { handle, size: stats.size, entries }; } catch (error) { await handle.close().catch(() => undefined); throw error; } } async function extractSnapshot(archive: string, target: string, signal?: AbortSignal): Promise { const zip = await readStoredZipEntries(archive, signal); const destinations = new Set(); let totalBytes = 0; try { for (const entry of zip.entries) { throwIfAborted(signal); const destinationKey = entry.name.toLowerCase(); if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); destinations.add(destinationKey); if (entry.uncompressedSize > MAX_SOURCE_ARCHIVE_BYTES || entry.uncompressedSize !== entry.compressedSize) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } totalBytes += entry.uncompressedSize; if (totalBytes > MAX_SOURCE_ARCHIVE_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const localHeader = await readZipBytes(zip.handle, entry.localOffset, 30, signal); if (localHeader.readUInt32LE(0) !== ZIP_LOCAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const localFlags = localHeader.readUInt16LE(6); const localMethod = localHeader.readUInt16LE(8); const localNameLength = localHeader.readUInt16LE(26); const localExtraLength = localHeader.readUInt16LE(28); if ((localFlags & 0x0001) !== 0 || localMethod !== ZIP_STORE_METHOD) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const localName = (await readZipBytes(zip.handle, entry.localOffset + 30, localNameLength, signal)).toString('utf8'); if (localName !== entry.name) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength; const dataEnd = dataStart + entry.compressedSize; if (dataStart > zip.size || dataEnd > zip.size || dataEnd < dataStart) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); const destination = join(target, ...entry.parts); await mkdir(dirname(destination), { recursive: true }); try { if (entry.compressedSize === 0) { await writeFile(destination, Buffer.alloc(0), { flag: 'wx', mode: 0o600 }); } else { const input = zip.handle.createReadStream({ start: dataStart, end: dataEnd - 1, autoClose: false }); const output = createWriteStream(destination, { flags: 'wx', mode: 0o600 }); await pipeline(input, output, { signal }); } const written = await lstat(destination); if (!written.isFile() || written.size !== entry.uncompressedSize) throw new Error('size mismatch'); } catch { await rm(destination, { force: true }).catch(() => undefined); throwIfAborted(signal); throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } } } finally { await zip.handle.close().catch(() => undefined); } } async function collectOutput(root: string, signal?: AbortSignal): Promise> { const files: Array<{ path: string; bytes: Buffer }> = []; let total = 0; async function walk(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((a, b) => comparePaths(a.name, b.name)); for (const entry of entries) { throwIfAborted(signal); const absolute = join(directory, entry.name); const stats = await lstat(absolute); if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); if (stats.isDirectory()) await walk(absolute); else { total += stats.size; if (files.length + 1 > MAX_OUTPUT_FILES || total > MAX_OUTPUT_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); files.push({ path: archivePath(relative(root, absolute)), bytes: await readFile(absolute) }); } } } try { await walk(root); } catch (error) { if (error instanceof ProjectReleaseBuildError) throw error; throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } files.sort((a, b) => comparePaths(a.path, b.path)); if (files.some((file) => file.path.toLowerCase() === 'release.json')) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); const index = files.find((file) => file.path === 'index.html'); if (!index) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); try { if (!new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).trim()) throw new Error('empty'); } catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } return files; } async function hashFile(filePath: string, signal?: AbortSignal): Promise<{ bytes: number; sha256: string }> { const hash = createHash('sha256'); let bytes = 0; const stream = createReadStream(filePath); try { for await (const chunk of stream) { throwIfAborted(signal); const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += value.length; hash.update(value); } } finally { stream.destroy(); } return { bytes, sha256: hash.digest('hex') }; } async function collectOutputMetadata( root: string, signal?: AbortSignal, ): Promise> { const files: Array<{ path: string; filePath: string; size: number; sha256: string }> = []; let total = 0; async function walk(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((a, b) => comparePaths(a.name, b.name)); for (const entry of entries) { throwIfAborted(signal); const absolute = join(directory, entry.name); const stats = await lstat(absolute); if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } if (stats.isDirectory()) { await walk(absolute); continue; } const path = archivePath(relative(root, absolute)); if (files.length + 1 > MAX_OUTPUT_FILES || total + stats.size > MAX_OUTPUT_BYTES) { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } const digest = await hashFile(absolute, signal); if (digest.bytes !== stats.size) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); total += digest.bytes; files.push({ path, filePath: absolute, size: digest.bytes, sha256: digest.sha256 }); } } try { await walk(root); } catch (error) { if (error instanceof ProjectReleaseBuildError) throw error; throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } files.sort((a, b) => comparePaths(a.path, b.path)); if (files.some((file) => file.path.toLowerCase() === 'release.json')) { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } const index = files.find((file) => file.path === 'index.html'); if (!index || index.size === 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); return files; } export type ReleaseBuildProgress = { phase: 'packaging' | 'installing' | 'building' | 'archiving' | 'complete'; percent: number; }; export type ReleaseBuildRuntimeContext = Partial & { /** Persistent npm cache supplied by Main so utility processes never need app.getPath(). */ npmCacheDir?: string; }; type ReleaseBuildInput = { projectPath: string; clientVersion: string; signal?: AbortSignal; onProgress?: (progress: ReleaseBuildProgress) => void; runtimeContext?: ReleaseBuildRuntimeContext; }; async function prepareProjectReleaseInternal( input: ReleaseBuildInput, retainBuffers: boolean, ): Promise { const projectKey = resolve(input.projectPath); if (activeReleaseProjects.has(projectKey)) { throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY'); } activeReleaseProjects.add(projectKey); const progress = (phase: ReleaseBuildProgress['phase'], percent: number): void => { input.onProgress?.({ phase, percent }); throwIfAborted(input.signal); }; let taskRoot: string; try { taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-')); } catch (error) { activeReleaseProjects.delete(projectKey); throw error; } try { const sourcePath = join(taskRoot, 'project.zip'); progress('packaging', 5); const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath }); progress('packaging', 20); const sourceBytes = retainBuffers ? await readFile(sourcePath) : undefined; const snapshot = join(taskRoot, 'snapshot'); const distRoot = join(taskRoot, 'owned-dist'); const npmUserConfig = join(taskRoot, 'empty-npmrc'); const npmCache = input.runtimeContext?.npmCacheDir ?? join(app.getPath('userData'), 'npm-cache'); await mkdir(snapshot, { recursive: true }); await writeFile(npmUserConfig, '', 'utf8'); await extractSnapshot(sourcePath, snapshot, input.signal); progress('installing', 25); const runtime = await resolvePublishRuntime(input.runtimeContext); await runElectronNode({ args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--progress=false', '--userconfig', npmUserConfig, '--cache', npmCache], cwd: snapshot, deadlineMs: 5 * 60_000, signal: input.signal, }); progress('installing', 55); let viteVersion = ''; try { const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown }; if (typeof vitePackage.version !== 'string') throw new Error('missing vite version'); viteVersion = vitePackage.version; } catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } // Vite config and plugins execute with desktop-user authority. This build is not a sandbox or trust proof. await runElectronNode({ args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'], cwd: snapshot, deadlineMs: 3 * 60_000, signal: input.signal, }); progress('building', 80); progress('archiving', 90); const builtPath = join(taskRoot, 'built-project.zip'); let files: Array<{ path: string; size: number; sha256: string }>; let builtBytes: Buffer | undefined; let builtArchiveDigest: string; let staticArtifact: StaticArtifactSnapshot | undefined; if (retainBuffers) { const output = await collectOutput(distRoot, input.signal); files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) })); const zip = new AdmZip(); for (const file of output) { zip.addFile(file.path, file.bytes, '', FILE_MODE); const entry = zip.getEntry(file.path); if (entry) entry.header.time = FIXED_ZIP_TIME; } builtBytes = zip.toBuffer(); await writeFile(builtPath, builtBytes); builtArchiveDigest = sha256(builtBytes); staticArtifact = createStaticArtifactSnapshot(output); } else { const metadata = await collectOutputMetadata(distRoot, input.signal); files = metadata.map(({ path, size, sha256: digest }) => ({ path, size, sha256: digest })); const streamed = await writeStoredZip( metadata.map((file) => ({ path: file.path, source: { filePath: file.filePath } })), builtPath, { signal: input.signal, date: FIXED_ZIP_TIME }, ); builtArchiveDigest = streamed.sha256; } const totalBytes = files.reduce((sum, file) => sum + file.size, 0); // Matches the server's canonical JSON: sorted files, sorted object keys, no whitespace. const artifactDigest = sha256(JSON.stringify(files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size, })))); const contract: ArtifactContract = { schema_version: 1, entry_path: 'index.html', source_digest: sourceBytes ? sha256(sourceBytes) : (await hashFile(sourcePath, input.signal)).sha256, built_archive_digest: builtArchiveDigest, artifact_digest: artifactDigest, file_count: files.length, total_bytes: totalBytes, files, security_profile: 'works-square-static-sandbox-v1', toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion }, }; const dispose = async (): Promise => { await rm(taskRoot, { recursive: true, force: true }); }; const prepared: PreparedRelease | PreparedReleaseSummary = retainBuffers ? { sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes!, summary }, builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes! }, distRoot, staticArtifact: staticArtifact!, contract, dispose, } : { sourceArchive: { path: sourcePath, name: 'project.zip', summary }, builtArchive: { path: builtPath, name: 'built-project.zip' }, contract, dispose, }; progress('complete', 100); activeReleaseProjects.delete(projectKey); return prepared; } catch (error) { activeReleaseProjects.delete(projectKey); await rm(taskRoot, { recursive: true, force: true }).catch(() => undefined); if (error instanceof ProjectReleaseBuildError) throw error; if (error instanceof PublishRuntimeError) throw new ProjectReleaseBuildError(error.code); throw error; } } export async function prepareProjectRelease(input: ReleaseBuildInput): Promise { return await prepareProjectReleaseInternal(input, true) as PreparedRelease; } export async function prepareProjectReleaseSummary(input: ReleaseBuildInput): Promise { return await prepareProjectReleaseInternal(input, false) as PreparedReleaseSummary; }