From a27ec7bf0cdf4e55afb0de439a5d926105cd6a5e Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Thu, 23 Jul 2026 13:36:17 -0700 Subject: [PATCH] Implement deterministic website build engine --- .gitignore | 3 + README.md | 62 +++- package.json | 3 +- server.ts | 40 +-- src/backend/buildEngine.test.ts | 181 ++++++++++++ src/backend/buildEngine.ts | 472 ++++++++++++++++++++++++++++++ src/backend/store.ts | 176 ++++++----- src/components/BuildEngineTab.tsx | 38 ++- 8 files changed, 847 insertions(+), 128 deletions(-) create mode 100644 src/backend/buildEngine.test.ts create mode 100644 src/backend/buildEngine.ts diff --git a/.gitignore b/.gitignore index 5a86d2a..89ba67e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ coverage/ *.log .env* !.env.example + +# Project-local SSH credentials (never commit private keys) +.ssh/ diff --git a/README.md b/README.md index 605c3df..e53f886 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,56 @@ -
-GHBanner -
+# Labyricorn Website Engine Control Plane -# Run and deploy your AI Studio app +A React control plane and Express backend for discovering Git-hosted Markdown, validating canonical site configuration, and producing deterministic static website releases. -This contains everything you need to run your app locally. +## Engine capabilities -View your app in AI Studio: https://ai.studio/apps/941c018f-56b5-4bf6-afbf-444c3f0a351f +The build engine now: -## Run Locally +- validates routes, route collisions, content state, raw-HTML policy, and media references; +- renders published content and navigation indexes into static HTML; +- escapes source HTML and renders a safe CommonMark-style subset; +- emits deterministic `build-manifest.json` and `checksums.json` files; +- calculates an artifact checksum from generated file checksums; +- writes releases atomically beneath the configured build root; +- activates releases through an atomic `current` symlink/junction; +- survives process restarts without reusing existing build numbers; and +- serves the active generated release through the live-preview endpoint. -**Prerequisites:** Node.js +## Development +Requires Node.js 22 or later. -1. Install dependencies: - `npm install` -2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: - `npm run dev` +```bash +npm install +npm test +npm run lint +npm run dev +``` + +Create a production bundle with: + +```bash +npm run build +npm start +``` + +The control plane listens on port `3000`. + +## Build artifacts + +By default, releases are written to the platform temporary directory under `labyricorn-builds/`. Set `LABYRICORN_BUILD_ROOT` to use a persistent release directory in production. + +Each successful release contains: + +- generated route directories with `index.html`; +- `404.html`; +- `build-manifest.json`; and +- `checksums.json`. + +Validation failures produce a failed build record but never publish a partial release directory. + +## Current boundaries + +- The built-in renderer is used until repository-provided theme templates are implemented. +- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable. +- Remote rsync deployment is still represented by the existing control-plane simulation and must not be treated as a completed production deploy path. diff --git a/package.json b/package.json index 9207bfb..28f1dbf 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "node dist/server.cjs", "preview": "vite preview", "clean": "rm -rf dist server.js", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "node --import tsx --test src/backend/buildEngine.test.ts" }, "dependencies": { "@google/genai": "^2.4.0", diff --git a/server.ts b/server.ts index 8c76f75..9f3defb 100644 --- a/server.ts +++ b/server.ts @@ -1,7 +1,6 @@ import express from "express"; import path from "path"; import fs from "fs"; -import crypto from "crypto"; import { createServer as createViteServer } from "vite"; import { store } from "./src/backend/store"; import { CredentialRef } from "./src/types"; @@ -561,41 +560,18 @@ async function startServer() { store.builds.find((b) => b.isActiveLocal) || store.builds[0]; let pageRoute = (req.query.route as string) || "/"; - const rootDir = "/tmp/labyricorn-builds/current"; + const rootDir = store.getCurrentBuildDirectory(); if (fs.existsSync(rootDir)) { - if (pageRoute === "/") { - // Try to list all generated pages to simulate an index - let links = ""; - try { - const items = store.contentItems; - links = items - .map( - (item) => - `
  • ${item.title}
  • `, - ) - .join(""); - } catch (e) {} - - return res.send(` - - - - Labyricorn Projection - - - -

    Local Canonical Site

    -

    Generated Projection from Repository Artifacts

    - - - - `); + const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/"); + const relativeRoute = routeWithoutQuery.replace(/^\/+/, ""); + const rootPath = `${path.resolve(rootDir)}${path.sep}`; + const targetPath = path.resolve(rootDir, relativeRoute, "index.html"); + if (!targetPath.startsWith(rootPath)) { + return res.status(400).json({ error: "Invalid preview route" }); } - - const targetPath = path.join(rootDir, pageRoute, "index.html"); if (fs.existsSync(targetPath)) { - return res.send(fs.readFileSync(targetPath, "utf-8")); + return res.type("html").send(fs.readFileSync(targetPath, "utf-8")); } } diff --git a/src/backend/buildEngine.test.ts b/src/backend/buildEngine.test.ts new file mode 100644 index 0000000..8372462 --- /dev/null +++ b/src/backend/buildEngine.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { BuildEngine, renderMarkdown } from "./buildEngine"; +import { BuildEngineInput } from "./buildEngine"; +import { ContentItem, SiteConfig, ThemeConfig } from "../types"; + +const siteConfig: SiteConfig = { + protocol: "labyricorn-site/v1", + site: { + id: "test-site", + title: "Test Site", + baseUrl: "https://example.test", + language: "en-US", + }, + navigation: [ + { id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" }, + { id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" }, + ], + contentModels: [], + sourcesFile: "./sources.yml", + styleInstancesPath: "./styles", + pagesPath: "./pages", + navigationFile: "./navigation.yml", + pushIntegrationsPath: "./push", + theme: { source: "site-definition", path: "/.theme" }, + markdown: { + dialect: "commonmark", + rawHtmlPolicy: "disabled", + rawHtmlEnabled: false, + extensions: { + tables: true, + taskLists: true, + footnotes: true, + definitionLists: true, + headingAnchors: true, + fencedCode: true, + syntaxHighlighting: true, + callouts: true, + youtube: true, + wikipediaLinks: true, + }, + }, + hosting: { + engine: "nginx", + production: { enabled: true, hostname: "example.test", listen: 80 }, + staging: { enabled: true, hostname: "preview.example.test", listen: 8080 }, + releases: { retainCount: 5 }, + }, + buildPolicy: { + staging: { enabled: true, requireApproval: true }, + localActivation: { automatic: false }, + push: { automatic: false }, + }, + rawYaml: "", +}; + +const themeConfig: ThemeConfig = { + id: "test-theme", + name: "Test Theme", + version: "1.0.0", + path: "/.theme", + templates: {}, + styles: [], + scripts: [], + supportsPackages: [], + isValidated: true, + validationErrors: [], +}; + +const content = (overrides: Partial = {}): ContentItem => ({ + id: "article-one", + title: "Article One", + slug: "article-one", + published: "2026-01-01T00:00:00.000Z", + status: "published", + artifactType: "article", + summary: "A deterministic article.", + tags: ["test"], + aliases: [], + sourceRepo: "content", + path: "articles/article-one.md", + contentMarkdown: "# Hello\n\nThis is **safe** markdown.", + mediaReferences: [], + youtubeDirectives: [], + wikipediaLinks: [], + validationStatus: "valid", + validationMessages: [], + route: "/articles/article-one/", + styleInstanceId: "default", + ...overrides, +}); + +const input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({ + buildId, + siteConfig, + contentItems: items, + mediaAssets: [], + sourceCommits: { content: "abc123" }, + themeConfig, +}); + +test("build output is deterministic for equivalent inputs", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); + try { + const engine = new BuildEngine(root); + const firstItem = content(); + const secondItem = content({ + id: "article-two", + title: "Article Two", + slug: "article-two", + path: "articles/article-two.md", + route: "/articles/article-two/", + }); + const first = engine.build(input("build-one", [firstItem, secondItem])); + const second = engine.build(input("build-two", [secondItem, firstItem])); + + assert.equal(first.success, true); + assert.equal(second.success, true); + assert.equal(first.artifactChecksum, second.artifactChecksum); + assert.deepEqual(first.generatedFiles, second.generatedFiles); + assert.ok(fs.existsSync(path.join(first.outputDirectory, "build-manifest.json"))); + assert.ok(fs.existsSync(path.join(first.outputDirectory, "checksums.json"))); + assert.match( + fs.readFileSync(path.join(first.outputDirectory, "articles/article-one/index.html"), "utf8"), + /

    Hello<\/h1>/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("route collisions fail validation without writing a release", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); + try { + const engine = new BuildEngine(root); + const result = engine.build( + input("collision", [content(), content({ id: "article-two", route: "/articles/article-one/" })]), + ); + + assert.equal(result.success, false); + assert.equal(result.validationReport.summary.passed, false); + assert.deepEqual(result.validationReport.routeCollisions, ["/articles/article-one/"]); + assert.equal(fs.existsSync(result.outputDirectory), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("raw HTML is rejected when the site policy disables it", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); + try { + const engine = new BuildEngine(root); + const result = engine.build(input("raw-html", [content({ contentMarkdown: "" })])); + + assert.equal(result.success, false); + assert.equal(result.validationReport.htmlPolicyViolations.length, 1); + assert.equal(fs.existsSync(result.outputDirectory), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("raw HTML inside fenced code remains a valid code example", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); + try { + const engine = new BuildEngine(root); + const result = engine.build( + input("html-code-example", [content({ contentMarkdown: "```html\n
    example
    \n```" })]), + ); + assert.equal(result.success, true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("markdown renderer escapes source HTML", () => { + assert.equal(renderMarkdown("Text "), "

    Text <img src=x>

    "); +}); diff --git a/src/backend/buildEngine.ts b/src/backend/buildEngine.ts new file mode 100644 index 0000000..b630eda --- /dev/null +++ b/src/backend/buildEngine.ts @@ -0,0 +1,472 @@ +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + ContentItem, + MediaAsset, + SiteConfig, + ThemeConfig, + ValidationReport, +} from "../types"; + +export const BUILDER_VERSION = "1.0.0-labyricorn"; + +export interface BuildEngineInput { + buildId: string; + siteConfig: SiteConfig; + contentItems: ContentItem[]; + mediaAssets: MediaAsset[]; + sourceCommits: Record; + themeConfig: ThemeConfig; +} + +export interface BuildEngineResult { + success: boolean; + outputDirectory: string; + generatedRoutesCount: number; + artifactChecksum: string; + artifactSizeBytes: number; + validationReport: ValidationReport; + generatedFiles: string[]; +} + +const emptyReport = (): ValidationReport => ({ + errors: [], + warnings: [], + missingMedia: [], + routeCollisions: [], + htmlPolicyViolations: [], + brokenLinks: [], + summary: { totalErrors: 0, totalWarnings: 0, passed: true }, +}); + +const escapeHtml = (value: string): string => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const stableValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableValue(child)]), + ); + } + return value; +}; + +const stableJson = (value: unknown): string => + `${JSON.stringify(stableValue(value), null, 2)}\n`; + +const sha256 = (value: string | Buffer): string => + crypto.createHash("sha256").update(value).digest("hex"); + +const normalizeRoute = (route: string): string => { + const trimmed = route.trim(); + if (!trimmed || trimmed.includes("\0") || /[?#]/.test(trimmed)) { + throw new Error(`Invalid route '${route}'`); + } + + const segments = trimmed + .replace(/\\/g, "/") + .split("/") + .filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new Error(`Route traversal is not allowed: '${route}'`); + } + + return segments.length === 0 ? "/" : `/${segments.join("/")}/`; +}; + +const routeFile = (route: string): string => { + const normalized = normalizeRoute(route); + return normalized === "/" + ? "index.html" + : `${normalized.slice(1)}index.html`; +}; + +const renderInlineMarkdown = (value: string): string => { + const codeTokens: string[] = []; + let rendered = value.replace(/`([^`]+)`/g, (_match, code: string) => { + const token = `\u0000CODE${codeTokens.length}\u0000`; + codeTokens.push(`${escapeHtml(code)}`); + return token; + }); + + rendered = escapeHtml(rendered) + .replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, + '$1', + ) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1"); + + return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => + codeTokens[Number(index)] ?? "", + ); +}; + +export const renderMarkdown = (markdown: string): string => { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const blocks: string[] = []; + let paragraph: string[] = []; + let list: string[] = []; + let code: string[] | null = null; + let codeLanguage = ""; + + const flushParagraph = () => { + if (paragraph.length > 0) { + blocks.push(`

    ${renderInlineMarkdown(paragraph.join(" "))}

    `); + paragraph = []; + } + }; + const flushList = () => { + if (list.length > 0) { + blocks.push(`
      ${list.map((item) => `
    • ${renderInlineMarkdown(item)}
    • `).join("")}
    `); + list = []; + } + }; + + for (const line of lines) { + const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/); + if (fence) { + if (code === null) { + flushParagraph(); + flushList(); + code = []; + codeLanguage = fence[1]; + } else { + const languageClass = codeLanguage + ? ` class="language-${escapeHtml(codeLanguage)}"` + : ""; + blocks.push(`
    ${escapeHtml(code.join("\n"))}
    `); + code = null; + codeLanguage = ""; + } + continue; + } + if (code !== null) { + code.push(line); + continue; + } + + if (!line.trim()) { + flushParagraph(); + flushList(); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + const level = heading[1].length; + blocks.push(`${renderInlineMarkdown(heading[2])}`); + continue; + } + + const listItem = line.match(/^[-*]\s+(.+)$/); + if (listItem) { + flushParagraph(); + list.push(listItem[1]); + continue; + } + + if (line.startsWith("> ")) { + flushParagraph(); + flushList(); + blocks.push(`
    ${renderInlineMarkdown(line.slice(2))}
    `); + continue; + } + + paragraph.push(line.trim()); + } + + if (code !== null) { + blocks.push(`
    ${escapeHtml(code.join("\n"))}
    `); + } + flushParagraph(); + flushList(); + return blocks.join("\n"); +}; + +export class BuildEngine { + readonly outputRoot: string; + + constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) { + this.outputRoot = path.resolve(outputRoot); + } + + buildDirectory(buildId: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(buildId)) { + throw new Error(`Invalid build ID '${buildId}'`); + } + return path.join(this.outputRoot, buildId); + } + + currentDirectory(): string { + return path.join(this.outputRoot, "current"); + } + + nextBuildNumber(): number { + if (!fs.existsSync(this.outputRoot)) return 1; + const highestExisting = fs + .readdirSync(this.outputRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name.match(/^build-(\d+)$/)?.[1]) + .filter((value): value is string => Boolean(value)) + .map(Number) + .reduce((highest, value) => Math.max(highest, value), 0); + return highestExisting + 1; + } + + validate(input: BuildEngineInput): ValidationReport { + const report = emptyReport(); + const claimedRoutes = new Map(); + const claim = (route: string, owner: string) => { + try { + const normalized = normalizeRoute(route); + const existing = claimedRoutes.get(normalized); + if (existing && existing !== owner) { + report.routeCollisions.push(normalized); + report.errors.push({ + code: "E_ROUTE_COLLISION", + message: `Route '${normalized}' is claimed by both ${existing} and ${owner}.`, + category: "routing", + }); + } else { + claimedRoutes.set(normalized, owner); + } + } catch (error) { + report.errors.push({ + code: "E_ROUTE_INVALID", + message: error instanceof Error ? error.message : String(error), + category: "routing", + }); + } + }; + + claim("/", "site-home"); + for (const navigation of input.siteConfig.navigation) { + if (navigation.route !== "/") claim(navigation.route, `navigation:${navigation.id}`); + } + + const knownMedia = new Set(input.mediaAssets.map((asset) => asset.publicNamespacePath)); + for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) { + claim(item.route, `content:${item.id}`); + if (item.validationStatus === "error") { + report.errors.push({ + code: "E_CONTENT_INVALID", + message: `Content item '${item.id}' failed source validation.`, + file: item.path, + category: "content", + }); + } + const markdownWithoutCode = item.contentMarkdown + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]*`/g, ""); + if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) { + report.htmlPolicyViolations.push(item.path); + report.errors.push({ + code: "E_RAW_HTML_DISABLED", + message: `Raw HTML is disabled but was found in '${item.path}'.`, + file: item.path, + category: "markdown", + }); + } + for (const reference of item.mediaReferences) { + if (!/^https?:\/\//.test(reference) && !knownMedia.has(reference)) { + report.missingMedia.push(reference); + } + } + } + + for (const missing of [...new Set(report.missingMedia)].sort()) { + report.errors.push({ + code: "E_MEDIA_MISSING", + message: `Referenced media '${missing}' was not discovered.`, + category: "media", + }); + } + for (const asset of input.mediaAssets.filter((candidate) => !candidate.isValidated)) { + report.warnings.push({ + code: "W_MEDIA_UNVALIDATED", + message: `Media asset '${asset.originalPath}' has not been validated.`, + file: asset.originalPath, + category: "media", + }); + } + + report.routeCollisions = [...new Set(report.routeCollisions)].sort(); + report.missingMedia = [...new Set(report.missingMedia)].sort(); + report.htmlPolicyViolations = [...new Set(report.htmlPolicyViolations)].sort(); + report.summary = { + totalErrors: report.errors.length, + totalWarnings: report.warnings.length, + passed: report.errors.length === 0, + }; + return report; + } + + build(input: BuildEngineInput): BuildEngineResult { + const validationReport = this.validate(input); + const outputDirectory = this.buildDirectory(input.buildId); + if (!validationReport.summary.passed) { + return { + success: false, + outputDirectory, + generatedRoutesCount: 0, + artifactChecksum: "", + artifactSizeBytes: 0, + validationReport, + generatedFiles: [], + }; + } + + fs.mkdirSync(this.outputRoot, { recursive: true }); + const temporaryDirectory = path.join( + this.outputRoot, + `.${input.buildId}.tmp-${process.pid}-${crypto.randomUUID()}`, + ); + fs.mkdirSync(temporaryDirectory, { recursive: true }); + + try { + const publishedItems = input.contentItems + .filter((item) => item.status === "published") + .sort((left, right) => normalizeRoute(left.route).localeCompare(normalizeRoute(right.route)) || left.id.localeCompare(right.id)); + const generatedRoutes = new Set(); + const generatedFiles: string[] = []; + + const navigation = input.siteConfig.navigation + .map((entry) => `${escapeHtml(entry.label)}`) + .join(""); + const layout = (title: string, body: string) => ` + + + + +${escapeHtml(title)} | ${escapeHtml(input.siteConfig.site.title)} + + +
    ${body}
    + +`; + const writeRoute = (route: string, html: string) => { + const normalized = normalizeRoute(route); + const relativeFile = routeFile(normalized); + const absoluteFile = path.join(temporaryDirectory, relativeFile); + fs.mkdirSync(path.dirname(absoluteFile), { recursive: true }); + fs.writeFileSync(absoluteFile, html, "utf8"); + generatedRoutes.add(normalized); + generatedFiles.push(relativeFile.replace(/\\/g, "/")); + }; + + const homeItems = publishedItems + .map((item) => ``) + .join(""); + writeRoute("/", layout(input.siteConfig.site.title, `

    ${escapeHtml(input.siteConfig.site.title)}

    ${homeItems || "

    No published content.

    "}`)); + + for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) { + const items = publishedItems + .filter((item) => item.artifactType === entry.contentModel) + .map((item) => ``) + .join(""); + writeRoute(entry.route, layout(entry.label, `

    ${escapeHtml(entry.label)}

    ${items || "

    No published content.

    "}`)); + } + + for (const item of publishedItems) { + const embeds = item.youtubeDirectives + .filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId)) + .map((directive) => `

    ${escapeHtml(directive.title)}

    `) + .join(""); + writeRoute( + item.route, + layout(item.title, `

    ${escapeHtml(item.title)}

    ${escapeHtml(item.summary)}

    ${renderMarkdown(item.contentMarkdown)}${embeds}
    `), + ); + } + + const errorPage = layout("Not Found", "

    404

    The requested page was not found.

    "); + fs.writeFileSync(path.join(temporaryDirectory, "404.html"), errorPage, "utf8"); + generatedFiles.push("404.html"); + + const manifest = { + protocol: input.siteConfig.protocol, + builderVersion: BUILDER_VERSION, + site: { + id: input.siteConfig.site.id, + baseUrl: input.siteConfig.site.baseUrl, + language: input.siteConfig.site.language, + }, + sourceCommits: input.sourceCommits, + theme: { id: input.themeConfig.id, version: input.themeConfig.version }, + routes: [...generatedRoutes].sort(), + media: input.mediaAssets + .map((asset) => ({ path: asset.publicNamespacePath, sha256: null, sizeBytes: asset.sizeBytes })) + .sort((left, right) => left.path.localeCompare(right.path)), + }; + fs.writeFileSync(path.join(temporaryDirectory, "build-manifest.json"), stableJson(manifest), "utf8"); + generatedFiles.push("build-manifest.json"); + + const checksums = Object.fromEntries( + generatedFiles + .sort() + .map((relativeFile) => [relativeFile, sha256(fs.readFileSync(path.join(temporaryDirectory, relativeFile)))]), + ); + const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums }); + fs.writeFileSync(path.join(temporaryDirectory, "checksums.json"), checksumsDocument, "utf8"); + generatedFiles.push("checksums.json"); + + const artifactChecksum = `sha256-${sha256(checksumsDocument)}`; + const artifactSizeBytes = generatedFiles.reduce( + (total, relativeFile) => total + fs.statSync(path.join(temporaryDirectory, relativeFile)).size, + 0, + ); + + if (fs.existsSync(outputDirectory)) { + throw new Error(`Build output already exists: ${outputDirectory}`); + } + fs.renameSync(temporaryDirectory, outputDirectory); + + return { + success: true, + outputDirectory, + generatedRoutesCount: generatedRoutes.size, + artifactChecksum, + artifactSizeBytes, + validationReport, + generatedFiles: generatedFiles.sort(), + }; + } catch (error) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + throw error; + } + } + + activate(buildId: string): string { + const buildDirectory = this.buildDirectory(buildId); + if (!fs.existsSync(path.join(buildDirectory, "build-manifest.json"))) { + throw new Error(`Build '${buildId}' is missing a build manifest.`); + } + + fs.mkdirSync(this.outputRoot, { recursive: true }); + const current = this.currentDirectory(); + const next = path.join(this.outputRoot, `.current-${process.pid}-${crypto.randomUUID()}`); + fs.symlinkSync(buildDirectory, next, process.platform === "win32" ? "junction" : "dir"); + try { + if (fs.existsSync(current) || fs.lstatSync(current, { throwIfNoEntry: false })) { + fs.rmSync(current, { recursive: true, force: true }); + } + fs.renameSync(next, current); + } catch (error) { + fs.rmSync(next, { recursive: true, force: true }); + throw error; + } + return current; + } +} diff --git a/src/backend/store.ts b/src/backend/store.ts index 214e2d6..adea041 100644 --- a/src/backend/store.ts +++ b/src/backend/store.ts @@ -19,7 +19,7 @@ import { import YAML from "yaml"; import fs from "fs"; import path from "path"; -import crypto from "crypto"; +import { BuildEngine, BUILDER_VERSION } from "./buildEngine"; export class LabyricornStore { siteConfig: SiteConfig; @@ -34,6 +34,7 @@ export class LabyricornStore { pushTargets: PushTarget[]; credentials: CredentialRef[]; auditLogs: AuditLogEntry[]; + private readonly buildEngine = new BuildEngine(); constructor() { this.gitSources = []; @@ -193,7 +194,11 @@ export class LabyricornStore { } runBuild(): BuildRecord { - const nextBuildNum = this.builds.length + 1; + const buildStartedAt = Date.now(); + const nextBuildNum = Math.max( + this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1, + this.buildEngine.nextBuildNumber(), + ); const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`; const timestamp = new Date().toISOString(); @@ -262,38 +267,46 @@ export class LabyricornStore { step: "building", }); - // Actual build simulation logic into /tmp - const buildDir = `/tmp/labyricorn-builds/${buildId}`; - let artifactSizeBytes = 0; - let checksum = "sha256-"; + let validationReport: ValidationReport = { + errors: [], + warnings: [], + missingMedia: [], + routeCollisions: [], + htmlPolicyViolations: [], + brokenLinks: [], + summary: { totalErrors: 0, totalWarnings: 0, passed: true }, + }; + let engineResult: ReturnType; try { - if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true }); - - // Generate some actual files based on content items - this.contentItems.forEach((item) => { - const routePath = path.join(buildDir, item.route); - if (!fs.existsSync(routePath)) - fs.mkdirSync(routePath, { recursive: true }); - - const htmlContent = `${item.title}

    ${item.title}

    ${item.summary}

    `; - fs.writeFileSync(path.join(routePath, "index.html"), htmlContent); - artifactSizeBytes += htmlContent.length; + engineResult = this.buildEngine.build({ + buildId, + siteConfig: this.siteConfig, + contentItems: this.contentItems, + mediaAssets: this.mediaAssets, + sourceCommits, + themeConfig: this.themeConfig, }); - - const checksumContent = this.contentItems.map((i) => i.id).join("-"); - checksum = `sha256-${crypto.createHash("sha256").update(checksumContent).digest("hex")}`; + validationReport = engineResult.validationReport; + if (!engineResult.success) { + const validationError = new Error( + `Validation failed with ${validationReport.summary.totalErrors} error(s).`, + ); + (validationError as Error & { validationReport: ValidationReport }).validationReport = + validationReport; + throw validationError; + } logs.push({ timestamp: new Date().toISOString(), level: "success", - message: `Static site build complete! Output written to ${buildDir}`, + message: `Static site build complete. Output written to ${engineResult.outputDirectory}`, step: "built", }); logs.push({ timestamp: new Date().toISOString(), level: "success", - message: `Generated build-manifest.json & checksums.json (${checksum.substring(0, 16)}...)`, + message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`, step: "built", }); } catch (e: any) { @@ -303,21 +316,8 @@ export class LabyricornStore { message: `Build failed: ${e.message}`, step: "building", }); - const failedBuild: BuildRecord = { - id: buildId, - buildNumber: nextBuildNum, - timestamp, - durationMs: 450, - status: "failed", - siteDefCommit: "local", - sourceCommits, - themeVersion: this.themeConfig.version, - builderVersion: "1.0.0-labyricorn", - generatedRoutesCount: 0, - mediaCount: 0, - artifactChecksum: "", - artifactSizeBytes: 0, - validationReport: { + const failureReport: ValidationReport = + e.validationReport || { errors: [ { code: "E_BUILD_FAIL", message: e.message, category: "build" }, ], @@ -327,7 +327,22 @@ export class LabyricornStore { htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 1, totalWarnings: 0, passed: false }, - }, + }; + const failedBuild: BuildRecord = { + id: buildId, + buildNumber: nextBuildNum, + timestamp, + durationMs: Date.now() - buildStartedAt, + status: "failed", + siteDefCommit: "local", + sourceCommits, + themeVersion: this.themeConfig.version, + builderVersion: BUILDER_VERSION, + generatedRoutesCount: 0, + mediaCount: 0, + artifactChecksum: "", + artifactSizeBytes: 0, + validationReport: failureReport, logs, stages: [ { name: "Triggered", status: "succeeded", durationMs: 10 }, @@ -341,12 +356,20 @@ export class LabyricornStore { status: "succeeded", durationMs: 450, }, - { name: "Metadata validation", status: "succeeded", durationMs: 120 }, - { name: "Artifact discovery", status: "succeeded", durationMs: 310 }, + { + name: "Metadata validation", + status: failureReport.summary.passed ? "succeeded" : "failed", + durationMs: 120, + }, + { + name: "Artifact discovery", + status: failureReport.summary.passed ? "succeeded" : "skipped", + durationMs: failureReport.summary.passed ? 310 : undefined, + }, { name: "Website projection build", - status: "failed", - durationMs: 150, + status: failureReport.summary.passed ? "failed" : "skipped", + durationMs: failureReport.summary.passed ? 150 : undefined, }, { name: "Local staging", status: "skipped" }, ], @@ -361,25 +384,17 @@ export class LabyricornStore { id: buildId, buildNumber: nextBuildNum, timestamp, - durationMs: 980, + durationMs: Date.now() - buildStartedAt, status: "built", siteDefCommit: "local", sourceCommits, themeVersion: this.themeConfig.version, - builderVersion: "1.0.0-labyricorn", - generatedRoutesCount: this.contentItems.length, + builderVersion: BUILDER_VERSION, + generatedRoutesCount: engineResult.generatedRoutesCount, mediaCount: this.mediaAssets.length, - artifactChecksum: checksum, - artifactSizeBytes, - validationReport: { - errors: [], - warnings: [], - missingMedia: [], - routeCollisions: [], - htmlPolicyViolations: [], - brokenLinks: [], - summary: { totalErrors: 0, totalWarnings: 0, passed: true }, - }, + artifactChecksum: engineResult.artifactChecksum, + artifactSizeBytes: engineResult.artifactSizeBytes, + validationReport, logs, stages: [ { name: "Triggered", status: "succeeded", durationMs: 10 }, @@ -402,6 +417,9 @@ export class LabyricornStore { isActiveLocal: false, }; + // Register the build before policy hooks so automatic activation can resolve it. + this.builds.unshift(newBuild); + // Auto staging if policy requires if (this.siteConfig.buildPolicy.staging.enabled) { newBuild.status = "staged"; @@ -420,11 +438,10 @@ export class LabyricornStore { this.activateReleaseLocally(buildId); } - this.builds.unshift(newBuild); this.addAudit( `Triggered Build #${nextBuildNum}`, "build", - `Successfully built static release ${buildId} (${checksum.substring(0, 12)})`, + `Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`, ); return newBuild; } @@ -439,10 +456,24 @@ export class LabyricornStore { if (build.status === "failed") return { success: false, message: `Cannot activate a failed build.` }; - // Mark previous active as false - for (const b of this.builds) { - b.isActiveLocal = false; - if (b.status === "active-local") b.status = "built"; + try { + this.buildEngine.activate(buildId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.nginxStatus.lastHealthCheckPassed = false; + this.addAudit( + "Activate Local Release Failed", + "nginx", + message, + "failure", + ); + return { success: false, message }; + } + + // Update in-memory state only after the release pointer changed successfully. + for (const previousBuild of this.builds) { + previousBuild.isActiveLocal = false; + if (previousBuild.status === "active-local") previousBuild.status = "built"; } build.isActiveLocal = true; @@ -451,19 +482,6 @@ export class LabyricornStore { this.nginxStatus.lastReloadTime = new Date().toISOString(); this.nginxStatus.lastHealthCheckPassed = true; - try { - if (fs.existsSync("/tmp/labyricorn-builds/current")) { - fs.unlinkSync("/tmp/labyricorn-builds/current"); - } - fs.symlinkSync( - `/tmp/labyricorn-builds/${buildId}`, - "/tmp/labyricorn-builds/current", - "dir", - ); - } catch (e) { - // Ignore symlink errors in test env - } - this.addAudit( "Activate Local Release", "nginx", @@ -475,6 +493,14 @@ export class LabyricornStore { }; } + getBuildRoot(): string { + return this.buildEngine.outputRoot; + } + + getCurrentBuildDirectory(): string { + return this.buildEngine.currentDirectory(); + } + pushToTarget( targetId: string, buildId: string, diff --git a/src/components/BuildEngineTab.tsx b/src/components/BuildEngineTab.tsx index f6002c7..a8e0801 100644 --- a/src/components/BuildEngineTab.tsx +++ b/src/components/BuildEngineTab.tsx @@ -320,19 +320,43 @@ export const BuildEngineTab: React.FC = ({ {/* TAB CONTENT: VALIDATION */} {activeTab === "validation" && (
    -
    +
    - + {selectedBuild.validationReport.summary.passed ? ( + + ) : ( + + )} - Validation Passed: 0 Fatal Errors, 0 Route Collisions + Validation {selectedBuild.validationReport.summary.passed ? "Passed" : "Failed"}: {selectedBuild.validationReport.summary.totalErrors} Errors, {selectedBuild.validationReport.summary.totalWarnings} Warnings
    -

    - All required metadata, Markdown dialect constraints, - YouTube embed IDs, and media namespace routes passed - verification. +

    + {selectedBuild.validationReport.routeCollisions.length} route collisions, {selectedBuild.validationReport.missingMedia.length} missing media references, and {selectedBuild.validationReport.htmlPolicyViolations.length} HTML policy violations.

    + + {selectedBuild.validationReport.errors.map((error, index) => ( +
    +
    {error.code}
    +
    {error.message}
    + {error.file &&
    {error.file}
    } +
    + ))} + + {selectedBuild.validationReport.warnings.map((warning, index) => ( +
    +
    {warning.code}
    +
    {warning.message}
    + {warning.file &&
    {warning.file}
    } +
    + ))}
    )}