Files
msd-core/get-shit-done/bin/lib/installer-migrations.cjs
2026-05-11 14:29:33 -04:00

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,
};