Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
234 lines
10 KiB
JavaScript
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,
|
|
};
|
|
}
|