353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
|
const INSTALL_STATE_NAME = 'gsd-install-state.json';
|
|
const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
|
|
|
|
function sha256File(filePath) {
|
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
}
|
|
|
|
function readJsonIfPresent(filePath, fallback) {
|
|
if (!fs.existsSync(filePath)) return fallback;
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function readInstallManifest(configDir) {
|
|
const manifest = readJsonIfPresent(path.join(configDir, MANIFEST_NAME), null);
|
|
if (!manifest || typeof manifest !== 'object') {
|
|
return { version: null, timestamp: null, mode: null, files: {} };
|
|
}
|
|
return {
|
|
version: manifest.version || null,
|
|
timestamp: manifest.timestamp || null,
|
|
mode: manifest.mode || null,
|
|
files: manifest.files && typeof manifest.files === 'object' ? manifest.files : {},
|
|
};
|
|
}
|
|
|
|
function readInstallState(configDir) {
|
|
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), null);
|
|
if (!state || typeof state !== 'object') {
|
|
return { schemaVersion: 1, appliedMigrations: [] };
|
|
}
|
|
return {
|
|
schemaVersion: state.schemaVersion || 1,
|
|
appliedMigrations: Array.isArray(state.appliedMigrations) ? state.appliedMigrations : [],
|
|
};
|
|
}
|
|
|
|
function writeInstallState(configDir, state) {
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
return state;
|
|
}
|
|
|
|
function normalizeRelPath(relPath) {
|
|
if (typeof relPath !== 'string' || relPath.trim() === '') {
|
|
throw new Error('migration action relPath must be a non-empty string');
|
|
}
|
|
const normalized = relPath.replace(/\\/g, '/');
|
|
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
|
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
|
}
|
|
const segments = normalized.split('/');
|
|
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
|
|
}
|
|
return segments.join('/');
|
|
}
|
|
|
|
function classifyArtifact(configDir, relPath, manifest) {
|
|
const normalized = normalizeRelPath(relPath);
|
|
const originalHash = manifest.files[normalized] || null;
|
|
const fullPath = path.join(configDir, normalized);
|
|
if (!fs.existsSync(fullPath)) {
|
|
return { classification: originalHash ? 'managed-missing' : 'missing', originalHash, currentHash: null };
|
|
}
|
|
const currentHash = sha256File(fullPath);
|
|
if (!originalHash) {
|
|
return { classification: 'unknown', originalHash: null, currentHash };
|
|
}
|
|
if (currentHash === originalHash) {
|
|
return { classification: 'managed-pristine', originalHash, currentHash };
|
|
}
|
|
return { classification: 'managed-modified', originalHash, currentHash };
|
|
}
|
|
|
|
function appliedMigrationIds(state) {
|
|
return new Set(
|
|
state.appliedMigrations
|
|
.filter((entry) => entry && typeof entry.id === 'string')
|
|
.map((entry) => entry.id)
|
|
);
|
|
}
|
|
|
|
function validateMigrationRecord(record, source) {
|
|
if (!record || typeof record !== 'object') {
|
|
throw new Error(`migration record must export an object: ${source}`);
|
|
}
|
|
if (typeof record.id !== 'string' || record.id.trim() === '') {
|
|
throw new Error(`migration record must include a non-empty id: ${source}`);
|
|
}
|
|
if (typeof record.plan !== 'function') {
|
|
throw new Error(`migration record must include a plan function: ${source}`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function discoverInstallerMigrations({ migrationsDir }) {
|
|
if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
|
|
return fs.readdirSync(migrationsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.cjs'))
|
|
.map((entry) => entry.name)
|
|
.sort()
|
|
.flatMap((fileName) => {
|
|
const source = path.join(migrationsDir, fileName);
|
|
delete require.cache[require.resolve(source)];
|
|
const exported = require(source);
|
|
const records = Array.isArray(exported) ? exported : [exported];
|
|
return records.map((record) => validateMigrationRecord(record, source));
|
|
});
|
|
}
|
|
|
|
function journalTimestamp(now) {
|
|
return now().replace(/[:.]/g, '-');
|
|
}
|
|
|
|
function ensureInsideConfig(configDir, relPath) {
|
|
const normalized = normalizeRelPath(relPath);
|
|
const fullPath = path.resolve(configDir, normalized);
|
|
const root = path.resolve(configDir);
|
|
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
|
|
throw new Error(`migration path escapes configDir: ${relPath}`);
|
|
}
|
|
return { normalized, fullPath };
|
|
}
|
|
|
|
function planInstallerMigrations({ configDir, migrations, now = () => new Date().toISOString() }) {
|
|
if (!configDir) throw new Error('configDir is required');
|
|
if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
|
|
|
|
const manifest = readInstallManifest(configDir);
|
|
const state = readInstallState(configDir);
|
|
const applied = appliedMigrationIds(state);
|
|
const pending = migrations.filter((migration) => migration && !applied.has(migration.id));
|
|
const actions = [];
|
|
const blocked = [];
|
|
const classifications = new Map();
|
|
const classify = (relPath) => {
|
|
const normalized = normalizeRelPath(relPath);
|
|
if (!classifications.has(normalized)) {
|
|
classifications.set(normalized, classifyArtifact(configDir, normalized, manifest));
|
|
}
|
|
return classifications.get(normalized);
|
|
};
|
|
|
|
for (const migration of pending) {
|
|
if (typeof migration.id !== 'string' || migration.id.trim() === '') {
|
|
throw new Error('migration id must be a non-empty string');
|
|
}
|
|
if (typeof migration.plan !== 'function') {
|
|
throw new Error(`migration ${migration.id} must provide a plan function`);
|
|
}
|
|
const plannedActions = migration.plan({
|
|
configDir,
|
|
manifest,
|
|
state,
|
|
now,
|
|
classifyArtifact: classify,
|
|
});
|
|
if (!Array.isArray(plannedActions)) {
|
|
throw new Error(`migration ${migration.id} plan must return an array`);
|
|
}
|
|
for (const rawAction of plannedActions) {
|
|
const relPath = normalizeRelPath(rawAction.relPath);
|
|
const classification = classify(relPath);
|
|
let protectedType = rawAction.type;
|
|
if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') {
|
|
protectedType = 'backup-and-remove';
|
|
}
|
|
if (rawAction.type === 'remove-managed' && classification.classification === 'unknown') {
|
|
protectedType = 'preserve-user';
|
|
}
|
|
const action = {
|
|
migrationId: migration.id,
|
|
type: protectedType,
|
|
relPath,
|
|
reason: rawAction.reason || migration.description || '',
|
|
classification: classification.classification,
|
|
originalHash: classification.originalHash,
|
|
currentHash: classification.currentHash,
|
|
};
|
|
if (action.type !== rawAction.type) {
|
|
action.requestedType = rawAction.type;
|
|
}
|
|
if (action.type === 'backup-and-remove') {
|
|
action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath);
|
|
}
|
|
if (action.classification === 'unknown') blocked.push(action);
|
|
actions.push(action);
|
|
}
|
|
}
|
|
|
|
return {
|
|
generatedAt: now(),
|
|
manifest,
|
|
state,
|
|
pendingMigrationIds: pending.map((migration) => migration.id),
|
|
actions,
|
|
blocked,
|
|
};
|
|
}
|
|
|
|
function uniqueActionMigrationIds(actions) {
|
|
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
|
|
}
|
|
|
|
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
|
|
if (!configDir) throw new Error('configDir is required');
|
|
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
|
|
if (Array.isArray(plan.blocked) && plan.blocked.length > 0) {
|
|
throw new Error(`migration plan has ${plan.blocked.length} blocked action(s)`);
|
|
}
|
|
|
|
const appliedAt = now();
|
|
const journalRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}.json`);
|
|
const journalPath = path.join(configDir, journalRelPath);
|
|
const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}-rollback`);
|
|
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
|
|
const journal = {
|
|
schemaVersion: 1,
|
|
appliedAt,
|
|
appliedMigrationIds: uniqueActionMigrationIds(plan.actions),
|
|
actions: [],
|
|
};
|
|
const rollback = [];
|
|
|
|
try {
|
|
for (const action of plan.actions) {
|
|
if (action.type === 'preserve-user') {
|
|
journal.actions.push({ ...action, status: 'preserved' });
|
|
continue;
|
|
}
|
|
if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove') {
|
|
throw new Error(`unsupported migration action type: ${action.type}`);
|
|
}
|
|
|
|
const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath);
|
|
if (!fs.existsSync(fullPath)) {
|
|
journal.actions.push({ ...action, status: 'missing' });
|
|
continue;
|
|
}
|
|
|
|
const rollbackPath = path.join(rollbackRoot, normalized);
|
|
fs.mkdirSync(path.dirname(rollbackPath), { recursive: true });
|
|
fs.copyFileSync(fullPath, rollbackPath);
|
|
rollback.push({ relPath: normalized, rollbackPath });
|
|
|
|
if (action.type === 'backup-and-remove') {
|
|
const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized);
|
|
const backupPath = path.join(configDir, backupRelPath);
|
|
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
fs.copyFileSync(fullPath, backupPath);
|
|
journal.actions.push({ ...action, backupRelPath, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
|
|
} else {
|
|
journal.actions.push({ ...action, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
|
|
}
|
|
fs.rmSync(fullPath, { force: true });
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
|
|
|
|
const state = readInstallState(configDir);
|
|
const applied = appliedMigrationIds(state);
|
|
const nextApplied = [...state.appliedMigrations];
|
|
for (const id of journal.appliedMigrationIds) {
|
|
if (!applied.has(id)) {
|
|
nextApplied.push({ id, appliedAt, journal: journalRelPath });
|
|
}
|
|
}
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: nextApplied,
|
|
});
|
|
|
|
return {
|
|
appliedMigrationIds: journal.appliedMigrationIds,
|
|
journalRelPath,
|
|
};
|
|
} catch (error) {
|
|
const rollbackFailures = [];
|
|
for (const entry of rollback.reverse()) {
|
|
const dest = path.join(configDir, entry.relPath);
|
|
try {
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.copyFileSync(entry.rollbackPath, dest);
|
|
} catch (rollbackError) {
|
|
rollbackFailures.push({
|
|
relPath: entry.relPath,
|
|
rollbackPath: entry.rollbackPath,
|
|
error: rollbackError.message,
|
|
});
|
|
}
|
|
}
|
|
if (rollbackFailures.length > 0) {
|
|
const rollbackError = new Error(`migration apply failed and rollback incomplete: ${error.message}`);
|
|
rollbackError.cause = error;
|
|
rollbackError.rollbackFailures = rollbackFailures;
|
|
throw rollbackError;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function runInstallerMigrations({
|
|
configDir,
|
|
migrationsDir = DEFAULT_MIGRATIONS_DIR,
|
|
migrations = discoverInstallerMigrations({ migrationsDir }),
|
|
now = () => new Date().toISOString(),
|
|
} = {}) {
|
|
const plan = planInstallerMigrations({ configDir, migrations, now });
|
|
if (plan.actions.length === 0) {
|
|
return {
|
|
appliedMigrationIds: [],
|
|
journalRelPath: null,
|
|
plan,
|
|
};
|
|
}
|
|
if (plan.blocked.length > 0) {
|
|
return {
|
|
appliedMigrationIds: [],
|
|
journalRelPath: null,
|
|
plan,
|
|
blocked: plan.blocked,
|
|
};
|
|
}
|
|
const result = applyInstallerMigrationPlan({ configDir, plan, now });
|
|
return { ...result, plan };
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_MIGRATIONS_DIR,
|
|
INSTALL_STATE_NAME,
|
|
MANIFEST_NAME,
|
|
applyInstallerMigrationPlan,
|
|
classifyArtifact,
|
|
discoverInstallerMigrations,
|
|
planInstallerMigrations,
|
|
readInstallManifest,
|
|
readInstallState,
|
|
runInstallerMigrations,
|
|
writeInstallState,
|
|
};
|