Files
vendoo/tools/release-manifest-lib.mjs
T
Masterluke77andGitHub 40071f718e Vendoo 1.43.0 – Production Update
Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
2026-07-10 13:16:21 +02:00

234 lines
10 KiB
JavaScript

import {
chmodSync,
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
export const MANIFEST_SCHEMA = 'vendoo-release-manifest-v1';
export const MANIFEST_FILE = 'release-manifest.json';
export const CONTROL_RELEASE_FILES = Object.freeze([MANIFEST_FILE]);
export const REQUIRED_RELEASE_FILES = Object.freeze([
'package.json',
'package-lock.json',
'bootstrap.mjs',
'server.mjs',
'lib/db.mjs',
'db/schema.sql',
'update-manifest.json',
'UPDATE_VENDOO.bat',
'scripts/updater/Vendoo.Updater.Preflight.ps1',
'scripts/updater/Vendoo.Updater.UI.ps1',
'scripts/updater/Vendoo.Updater.Host.ps1',
'scripts/updater/Vendoo.Updater.Worker.ps1',
'tools/apply-release-manifest.mjs',
]);
const allowedEnvTemplates = new Set(['.env.example', '.env.docker.example']);
const skippedTopLevelDirs = new Set([
'.git', 'node_modules', 'runtime', 'docker-data', 'uploads', 'backups', 'logs',
'sessions', 'tmp', 'temp', '__pycache__', 'release-output', 'operations', 'updates',
'modules', 'archiv', 'archive', 'archives', 'data',
]);
const skippedLocalAiDirs = new Set(['local-ai/comfyui', 'local-ai/downloads', 'local-ai/extract-temp']);
const skippedExactFiles = new Set([
MANIFEST_FILE,
'file_checksums_sha256.txt',
'.vendoo-install-mode', '.vendoo-github-state.json',
'pending-operation.json', 'last-operation.json', 'first-admin-code.txt',
]);
const skippedExtensions = new Set([
'.db', '.sqlite', '.sqlite3', '.log', '.key', '.pem', '.pfx', '.p12', '.pyc',
]);
export function normalizeReleasePath(value) {
return String(value).split('\\').join('/').replace(/^\.\//, '');
}
export function assertSafeReleasePath(value, label = 'Release-Pfad') {
const rel = normalizeReleasePath(value);
if (!rel || rel === '.' || isAbsolute(rel) || rel.startsWith('/') || /^[A-Za-z]:\//.test(rel)) {
throw new Error(`${label} ist nicht relativ: ${value}`);
}
const parts = rel.split('/');
if (parts.some(part => !part || part === '.' || part === '..')) {
throw new Error(`${label} enthält unzulässige Segmente: ${value}`);
}
if (parts[0].toLowerCase() === '.git') {
throw new Error(`${label} darf .git nicht verändern: ${value}`);
}
return rel;
}
export function shouldIncludeReleaseEntry(relativePath, stat) {
const rel = normalizeReleasePath(relativePath);
if (!rel) return false;
const parts = rel.split('/');
const top = parts[0].toLowerCase();
const name = basename(rel);
const lower = rel.toLowerCase();
if (skippedTopLevelDirs.has(top)) return false;
if (parts.some(part => part.toLowerCase() === '__pycache__')) return false;
if ([...skippedLocalAiDirs].some(dir => lower === dir || lower.startsWith(`${dir}/`))) return false;
if (/^\.env(?:\..+)?$/i.test(name) && !allowedEnvTemplates.has(rel)) return false;
if (skippedExactFiles.has(name.toLowerCase())) return false;
if (/\.(?:db|sqlite|sqlite3)-(?:wal|shm)$/i.test(name)) return false;
if (skippedExtensions.has(extname(name).toLowerCase())) return false;
if (stat.isSymbolicLink()) return false;
return stat.isDirectory() || stat.isFile();
}
export function sha256File(filePath) {
const hash = createHash('sha256');
hash.update(readFileSync(filePath));
return hash.digest('hex');
}
export function listReleaseFiles(root) {
const base = resolve(root);
const files = [];
function walk(current) {
for (const name of readdirSync(current).sort((a, b) => a.localeCompare(b, 'en'))) {
const absolute = join(current, name);
const rel = normalizeReleasePath(relative(base, absolute));
const stat = lstatSync(absolute);
if (!shouldIncludeReleaseEntry(rel, stat)) continue;
if (stat.isDirectory()) walk(absolute);
else files.push(rel);
}
}
walk(base);
return files;
}
export function buildReleaseManifest(root, { version, updaterVersion, removedFiles = [] } = {}) {
if (!version) throw new Error('Release-Version fehlt.');
if (!updaterVersion) throw new Error('Updater-Version fehlt.');
const base = resolve(root);
const files = listReleaseFiles(base).map(path => ({
path,
sha256: sha256File(join(base, path)),
size: statSync(join(base, path)).size,
}));
const normalizedRemoved = [...new Set(removedFiles.map(path => assertSafeReleasePath(path, 'removedFiles-Eintrag')))].sort();
const fileSet = new Set(files.map(entry => entry.path));
for (const required of REQUIRED_RELEASE_FILES) {
if (!fileSet.has(required)) throw new Error(`Pflichtdatei fehlt im Release-Payload: ${required}`);
}
for (const removed of normalizedRemoved) {
if (fileSet.has(removed)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${removed}`);
if (CONTROL_RELEASE_FILES.includes(removed)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${removed}`);
}
return {
schema: MANIFEST_SCHEMA,
version,
updaterVersion,
mode: 'overlay-with-explicit-removals',
requiredFiles: [...REQUIRED_RELEASE_FILES],
removedFiles: normalizedRemoved,
files,
};
}
export function readAndValidateManifest(sourceRoot, { allowExtraFiles = false } = {}) {
const base = resolve(sourceRoot);
const manifestPath = join(base, MANIFEST_FILE);
if (!existsSync(manifestPath)) throw new Error(`${MANIFEST_FILE} fehlt.`);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest.schema !== MANIFEST_SCHEMA) throw new Error(`Unbekanntes Manifest-Schema: ${manifest.schema}`);
if (manifest.mode !== 'overlay-with-explicit-removals') throw new Error(`Unzulässiger Release-Modus: ${manifest.mode}`);
if (!Array.isArray(manifest.files) || manifest.files.length === 0) throw new Error('Release-Manifest enthält keine Dateien.');
if (!Array.isArray(manifest.removedFiles)) throw new Error('removedFiles fehlt im Release-Manifest.');
const seen = new Set();
for (const entry of manifest.files) {
if (!entry || typeof entry !== 'object') throw new Error('Ungültiger Datei-Eintrag im Release-Manifest.');
const rel = assertSafeReleasePath(entry.path, 'Manifest-Dateipfad');
if (seen.has(rel)) throw new Error(`Doppelter Manifest-Dateipfad: ${rel}`);
seen.add(rel);
if (!/^[a-f0-9]{64}$/.test(String(entry.sha256 || ''))) throw new Error(`Ungültige SHA-256-Prüfsumme: ${rel}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0) throw new Error(`Ungültige Dateigröße: ${rel}`);
}
const removedSeen = new Set();
for (const value of manifest.removedFiles) {
const rel = assertSafeReleasePath(value, 'removedFiles-Eintrag');
if (removedSeen.has(rel)) throw new Error(`Doppelter removedFiles-Eintrag: ${rel}`);
removedSeen.add(rel);
if (seen.has(rel)) throw new Error(`Datei ist gleichzeitig Payload und Löschung: ${rel}`);
if (CONTROL_RELEASE_FILES.includes(rel)) throw new Error(`Steuerdatei darf nicht als Löschung markiert werden: ${rel}`);
}
const required = Array.isArray(manifest.requiredFiles) ? manifest.requiredFiles : REQUIRED_RELEASE_FILES;
for (const value of REQUIRED_RELEASE_FILES) {
if (!required.includes(value)) throw new Error(`Manifest-Pflichtdatei fehlt in requiredFiles: ${value}`);
if (!seen.has(value)) throw new Error(`Manifest-Payload enthält Pflichtdatei nicht: ${value}`);
}
const actualFiles = listReleaseFiles(base);
const actualSet = new Set(actualFiles);
if (!allowExtraFiles) {
for (const rel of actualFiles) if (!seen.has(rel)) throw new Error(`Sichere Paketdatei fehlt im Manifest: ${rel}`);
}
for (const rel of seen) if (!actualSet.has(rel)) throw new Error(`Manifest verweist auf fehlende Paketdatei: ${rel}`);
for (const entry of manifest.files) {
const filePath = join(base, entry.path);
const stat = statSync(filePath);
if (stat.size !== entry.size) throw new Error(`Dateigröße stimmt nicht: ${entry.path}`);
const actualHash = sha256File(filePath);
if (actualHash !== entry.sha256) throw new Error(`SHA-256 stimmt nicht: ${entry.path}`);
}
return manifest;
}
export function applyReleaseManifest(sourceRoot, destinationRoot) {
const source = resolve(sourceRoot);
const destination = resolve(destinationRoot);
if (source === destination) throw new Error('Quell- und Zielordner dürfen nicht identisch sein.');
if (!existsSync(join(destination, '.git'))) throw new Error(`Ziel ist kein geklontes Git-Repository: ${destination}`);
const manifest = readAndValidateManifest(source);
for (const rel of manifest.removedFiles) {
rmSync(join(destination, rel), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
let copied = 0;
for (const entry of manifest.files) {
const sourceFile = join(source, entry.path);
const targetFile = join(destination, entry.path);
mkdirSync(dirname(targetFile), { recursive: true });
copyFileSync(sourceFile, targetFile);
try { chmodSync(targetFile, statSync(sourceFile).mode & 0o777); } catch {}
const targetHash = sha256File(targetFile);
if (targetHash !== entry.sha256) throw new Error(`Zielprüfung nach Kopieren fehlgeschlagen: ${entry.path}`);
copied += 1;
}
let controlCopied = 0;
for (const rel of CONTROL_RELEASE_FILES) {
const sourceFile = join(source, rel);
const targetFile = join(destination, rel);
mkdirSync(dirname(targetFile), { recursive: true });
copyFileSync(sourceFile, targetFile);
if (sha256File(sourceFile) !== sha256File(targetFile)) {
throw new Error(`Steuerdatei-Prüfung nach Kopieren fehlgeschlagen: ${rel}`);
}
controlCopied += 1;
}
for (const required of REQUIRED_RELEASE_FILES) {
if (!existsSync(join(destination, required))) throw new Error(`Pflichtdatei fehlt nach Release-Anwendung: ${required}`);
}
for (const controlFile of CONTROL_RELEASE_FILES) {
if (!existsSync(join(destination, controlFile))) throw new Error(`Steuerdatei fehlt nach Release-Anwendung: ${controlFile}`);
}
return {
source,
destination,
version: manifest.version,
updaterVersion: manifest.updaterVersion,
copied,
controlCopied,
removed: manifest.removedFiles.length,
};
}