import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync, lstatSync } from "node:fs"; import { dirname, join } from "node:path"; import { parse, stringify } from "yaml"; export const serviceRoles = Object.freeze({ core: "core", frontend: "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", qdrant: "qdrant", embedding: "embedding", "embedding-model-init": "embedding" }); export const sha256 = (data) => createHash("sha256").update(data).digest("hex"); /** Only distribution assets enter the bundle: never a checkout, environment file or workspace. */ export function prepareBundle({ source, destination, platform, version, revision, images }) { const compose = parse(readFileSync(join(source, "compose.yaml"), "utf8")); if (Object.keys(compose.services).sort().join() !== Object.keys(serviceRoles).sort().join()) throw new Error("Release service contract changed; review packaging before publishing."); for (const [name, role] of Object.entries(serviceRoles)) { if (!/^docker\.io\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/.test(images[role] ?? "")) throw new Error("Release requires immutable Docker Hub image references."); delete compose.services[name].build; delete compose.services[name].pull_policy; compose.services[name].image = images[role]; compose.services[name].platform = platform; } const files = {}; const write = (name, data) => { mkdirSync(dirname(join(destination, name)), { recursive: true }); writeFileSync(join(destination, name), data); files[name] = sha256(data); }; write("compose.yaml", stringify(compose)); for (const name of ["deploy/compose.local.yaml", "deploy/compose.git-https.yaml", "deploy/compose.git-ssh.yaml", "docker/catalog-db-init.sql", "docker/embedding-model-init.sh"]) { const path = join(source, name); if (!lstatSync(path).isFile()) throw new Error("Release asset must be a regular tracked file."); write(name, readFileSync(path)); } // Any future source bind mount must be deliberately added to the asset allowlist. for (const service of Object.values(compose.services)) { for (const volume of service.volumes ?? []) { const sourcePath = typeof volume === "string" ? volume.split(":")[0] : volume.type === "bind" ? volume.source : undefined; if (sourcePath?.startsWith(".") && !files[sourcePath.replace(/^\.\//, "")]) throw new Error("Source bind mount is missing from the release bundle."); } } const manifest = { schema_version: 1, version, revision, validator_protocol: 1, requirements: { cpus: 2, memory_bytes: 4 * 2 ** 30, disk_bytes: 10 * 2 ** 30 }, components: ["pi", "catalog-migrations", "workspace-maintenance"], images: Object.fromEntries(Object.entries(images).map(([role, reference]) => [role, { [platform]: reference }])), files, compose: ["compose.yaml", "deploy/compose.local.yaml"] }; writeFileSync(join(destination, "release-manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); writeFileSync(join(destination, "README.md"), `# ThothII ${version} — ${platform}\n\nSource / Sorgente: ${revision}\n\nThis prerelease provides images and document/preflight tools. Non-interactive execution and real-host acceptance are separate follow-up tickets; this is not a certified complete installation.\nQuesta prerelease fornisce immagini e strumenti di preparazione/preflight. Esecuzione non interattiva e collaudi reali sono incrementi successivi: non è ancora un'installazione completa certificata.\n\nUse bin/tht and its sibling bin/tht-workspace-documents together; no Node, Python, Bun or application checkout is required on the consumer host.\nWindows: use the Linux amd64 bundle inside Ubuntu WSL2, not a native Windows shell.\n\n1. bin/tht workspace prepare --directory NEW_WORKSPACE --id practice --name Practice\n2. bin/tht workspace validate --directory WORKSPACE\n3. bin/tht installation prepare --directory NEW_PRIVATE_INSTALLATION\n4. bin/tht installation preflight --directory INSTALLATION --release ABSOLUTE_RELEASE_DIR/release-manifest.json\n5. Complete the commented documents, generate technical credentials explicitly, then run installation validate and installation plan with --installation ABSOLUTE_INSTALLATION_FILE.\n\nImages are pinned by digest; runtime credentials and user workspaces are never bundled.\nConsult the accompanying IT/EN guides for prepared documents and mandatory runtime checks.\n`); for (const [name, target] of [["standalone-manual-it.md", "GUIDE-IT.md"], ["standalone-manual-en.md", "GUIDE-EN.md"], ["installation-preflight.md", "PREFLIGHT.md"]]) writeFileSync(join(destination, target), readFileSync(join(source, "docs/install", name))); return manifest; }