import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { dirname, join, relative, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager'; import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime'; import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server'; 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; 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 class ProjectReleaseBuildError extends Error { constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') { 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 isSafeArchiveFile(entry: import('adm-zip').IZipEntry): boolean { const unixMode = (entry.attr >>> 16) & 0xffff; const unixType = unixMode & 0xf000; return unixType === 0 || unixType === 0x8000; } 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; } async function extractSnapshot(archive: string, target: string): Promise { const zip = new AdmZip(archive); const destinations = new Set(); for (const entry of zip.getEntries()) { const name = entry.entryName; const parts = safeArchivePath(name); if (entry.header.encripted || !parts || (!entry.isDirectory && !isSafeArchiveFile(entry))) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } if (entry.isDirectory) { const unixType = ((entry.attr >>> 16) & 0xffff) & 0xf000; if (unixType !== 0 && unixType !== 0x4000) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); continue; } const destinationKey = name.toLowerCase(); if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); destinations.add(destinationKey); if (entry.header.size > MAX_OUTPUT_BYTES) { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } const destination = join(target, ...parts); await mkdir(dirname(destination), { recursive: true }); try { const data = entry.getData(); await writeFile(destination, data, { flag: 'wx', mode: 0o600 }); } catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); } } } async function collectOutput(root: string): 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) { 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; } export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise { const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-')); try { const sourcePath = join(taskRoot, 'project.zip'); const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath }); const sourceBytes = await readFile(sourcePath); const snapshot = join(taskRoot, 'snapshot'); const distRoot = join(taskRoot, 'owned-dist'); const npmUserConfig = join(taskRoot, 'empty-npmrc'); const npmCache = join(taskRoot, 'npm-cache'); await mkdir(snapshot, { recursive: true }); await writeFile(npmUserConfig, '', 'utf8'); await extractSnapshot(sourcePath, snapshot); const runtime = await resolvePublishRuntime(); await runElectronNode({ args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache], cwd: snapshot, deadlineMs: 5 * 60_000, }); 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, }); const output = await collectOutput(distRoot); const zip = new AdmZip(); const files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) })); 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; } const builtBytes = zip.toBuffer(); const builtPath = join(taskRoot, 'built-project.zip'); await writeFile(builtPath, builtBytes); 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 staticArtifact = createStaticArtifactSnapshot(output); return { sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes, summary }, builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes }, distRoot, staticArtifact, contract: { schema_version: 1, entry_path: 'index.html', source_digest: sha256(sourceBytes), built_archive_digest: sha256(builtBytes), 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 }, }, dispose: async () => { await rm(taskRoot, { recursive: true, force: true }); }, }; } catch (error) { 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; } }