143 lines
8.8 KiB
JavaScript
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'));
|
|
}
|