Files
Enelix-EMS/tools/public-docs/sync.mjs
T
2026-10-06 14:32:05 +00:00

143 lines
8.8 KiB
JavaScript

import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { repositories, guides } from './config.mjs';
import { renderPage } from './render.mjs';
import { screenshotSources, screenshotInfo } from './images.mjs';
const root = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.resolve(process.env.DOCS_PUBLIC_DIR || '/home/agent/services/license/public');
const stateDir = path.resolve(process.env.DOCS_STATE_DIR || path.join(root, 'state'));
const branch = process.env.DOCS_BRANCH || 'develop';
if (!['develop', 'beta', 'main'].includes(branch)) throw new Error('Unsupported documentation branch');
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding:'utf8', timeout:60000, maxBuffer:4e6, stdio:['ignore', 'pipe', 'pipe'], env:{ ...process.env, GIT_TERMINAL_PROMPT:'0' } });
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 16);
const statusPath = path.join(publicDir, 'docs-status.json');
await fs.mkdir(stateDir, { recursive:true, mode:0o700 });
await fs.mkdir(publicDir, { recursive:true, mode:0o755 });
let lock;
try { lock = await fs.open(path.join(stateDir, 'sync.lock'), 'wx', 0o600); }
catch (error) { if (error.code === 'EEXIST') { console.error('Documentation sync already running; inspect stale lock after interruption.'); process.exit(1); } throw error; }
async function atomicWrite(target, content) {
const temporary = `${target}.${randomUUID()}.tmp`;
await fs.writeFile(temporary, content, { mode:0o644 });
await fs.rename(temporary, target);
}
try {
const pages = [];
const commits = {};
const mirrors = {};
for (const repo of repositories) {
const mirror = path.join(stateDir, `${repo.id}.git`);
await fs.mkdir(mirror, { recursive:true, mode:0o700 });
if (!existsSync(path.join(mirror, 'HEAD'))) {
git(mirror, 'init', '--bare');
git(mirror, 'remote', 'add', 'origin', repo.url);
}
// A separate read-only mirror never changes a developer checkout.
if (git(mirror, 'remote', 'get-url', 'origin').trim() !== repo.url) throw new Error('Unexpected source remote');
git(mirror, 'fetch', '--quiet', '--depth=1', 'origin', `refs/heads/${branch}`);
const commit = git(mirror, 'rev-parse', 'FETCH_HEAD').trim();
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid source revision');
commits[repo.name] = commit;
mirrors[repo.name] = mirror;
for (const [source, title, output] of repo.pages) {
const entry = git(mirror, 'ls-tree', commit, '--', source);
if (!/^100644 blob |^100755 blob /.test(entry)) throw new Error(`Missing regular documentation source: ${repo.name}/${source}`);
const markdown = git(mirror, 'show', `${commit}:${source}`);
if (markdown.length > 400000 || /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|\bENX-(?:[A-Z0-9]{4}-){3}[A-Z0-9]{4}\b/.test(markdown.replaceAll('ENX-XXXX-XXXX-XXXX-XXXX', ''))) throw new Error('Public documentation safety check failed');
pages.push({ repo:repo.name, source, title, output, markdown, commit, group:repo.id === 'ems' ? 'Enelix EMS' : 'Enelix Utils' });
}
}
let drafts = false;
for (const [name, title] of guides) {
const source = `docs/public/${name}.md`;
let markdown, commit = commits['Enelix-EMS'];
try { markdown = git(mirrors['Enelix-EMS'], 'show', `${commit}:${source}`); }
catch {
// Temporary bootstrap only until the newly written guides are approved for Git.
if (process.env.DOCS_ALLOW_DRAFTS !== 'true') throw new Error(`Guide not published in Git: ${source}`);
markdown = await fs.readFile(path.join(root, 'guides', `${name}.md`), 'utf8');
commit = null; drafts = true;
}
pages.unshift({ repo:'Enelix-EMS', source, title, output:`${name}.html`, markdown, commit, group:'Anleitungen' });
}
pages.sort((a, b) => a.group === 'Anleitungen' && b.group === 'Anleitungen' ? guides.findIndex(([key]) => `${key}.html` === a.output) - guides.findIndex(([key]) => `${key}.html` === b.output) : 0);
const images = [], imageData = new Map();
for (const source of screenshotSources) {
let commit = commits['Enelix-EMS'], data;
const mirror = mirrors['Enelix-EMS'];
const entry = git(mirror, 'ls-tree', commit, '--', source);
if (entry) {
if (!/^100644 blob |^100755 blob /.test(entry)) throw new Error('Screenshot must be a regular file');
data = execFileSync('git', ['show', `${commit}:${source}`], { cwd:mirror, timeout:60000, maxBuffer:4_000_000, stdio:['ignore','pipe','pipe'] });
} else {
if (process.env.DOCS_ALLOW_DRAFTS !== 'true') throw new Error('Screenshot not published in Git');
const local = path.join(root, 'guides/images', path.basename(source));
if (!(await fs.lstat(local)).isFile()) throw new Error('Draft screenshot must be a regular file');
data = await fs.readFile(local);
commit = null; drafts = true;
}
const image = { ...screenshotInfo(source, data), commit };
images.push(image); imageData.set(image.output, data);
}
const assets = {};
for (const name of ['site.css', 'site.js', 'prices.js']) assets[name] = await fs.readFile(path.join(root, 'assets', name), 'utf8');
const engine = await fs.readFile(path.join(root, 'render.mjs'), 'utf8') + await fs.readFile(path.join(root, 'images.mjs'), 'utf8');
const version = hash(JSON.stringify({ pages, assets, images, engine, branch }));
const checkedAt = new Date().toISOString();
const manifest = { branch, checkedAt, commits, drafts, version, images, pages:pages.map(({ repo, source, output, commit }) => ({ repo, source, output, commit })) };
const releases = path.join(publicDir, '.documentation-releases');
const release = path.join(releases, version);
const current = path.join(publicDir, 'docs');
await fs.mkdir(releases, { recursive:true, mode:0o755 });
if (!existsSync(release)) {
const staging = path.join(releases, `.staging-${randomUUID()}`);
await fs.mkdir(staging, { mode:0o755 });
try {
for (const page of pages) {
const target = path.join(staging, page.output);
await fs.mkdir(path.dirname(target), { recursive:true, mode:0o755 });
await fs.writeFile(target, renderPage(page, pages, manifest, version), { mode:0o644 });
}
for (const [name, content] of Object.entries(assets)) {
const extension = path.extname(name), stem = path.basename(name, extension);
await fs.writeFile(path.join(staging, `${stem}-${version}${extension}`), content, { mode:0o644 });
}
await fs.mkdir(path.join(staging, 'images'), { mode:0o755 });
for (const [output, data] of imageData) await fs.writeFile(path.join(staging, output), data, { mode:0o644 });
// Cached HTML may still reference the previous release's fingerprinted assets.
if (existsSync(current)) for (const name of await fs.readdir(current)) {
if (/^(site|prices)-[a-f0-9]{16}\.(css|js)$/.test(name)) await fs.copyFile(path.join(current, name), path.join(staging, name));
}
const previousImages = path.join(current, 'images');
if (existsSync(previousImages)) for (const name of await fs.readdir(previousImages)) {
if (/^symcon-[a-z-]+-[a-f0-9]{16}\.png$/.test(name)) await fs.copyFile(path.join(previousImages, name), path.join(staging, 'images', name));
}
await fs.writeFile(path.join(staging, 'manifest.json'), JSON.stringify(manifest), { mode:0o644 });
await fs.rename(staging, release);
} catch (error) { await fs.rm(staging, { recursive:true, force:true }); throw error; }
}
const existing = await fs.lstat(current).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
if (existing && !existing.isSymbolicLink()) throw new Error('Refusing to replace an existing documentation directory');
const temporaryLink = path.join(publicDir, `.docs-${randomUUID()}`);
await fs.symlink(path.relative(publicDir, release), temporaryLink);
await fs.rename(temporaryLink, current);
await atomicWrite(statusPath, JSON.stringify({ ok:true, lastAttempt:checkedAt, lastSuccess:checkedAt, branch, commits, drafts, version }));
console.log(JSON.stringify({ ok:true, branch, commits, drafts, version, pages:pages.length }));
} catch {
let previous = {};
try { previous = JSON.parse(await fs.readFile(statusPath, 'utf8')); } catch { /* No successful publication yet. */ }
await atomicWrite(statusPath, JSON.stringify({ ...previous, ok:false, lastAttempt:new Date().toISOString() }));
console.error('Documentation sync failed; last successful publication retained. Check source availability, guide publication and permissions.');
process.exitCode = 1;
} finally {
await lock.close();
await fs.unlink(path.join(stateDir, 'sync.lock'));
}