import { randomUUID } from 'node:crypto'; import { access, rename, rm } from 'node:fs/promises'; import path from 'node:path'; async function exists(candidate) { return access(candidate).then( () => true, () => false ); } function backupPath(target) { return path.join( path.dirname(target), `.${path.basename(target)}.toolbox-backup-${randomUUID()}` ); } export async function publishStagedTargets( targets, { force = false, renamePath = rename, removePath = rm } = {} ) { if (!Array.isArray(targets) || targets.length === 0) throw new Error('At least one staged publication target is required.'); const normalized = targets.map(({ staged, target }) => ({ staged: path.resolve(staged), target: path.resolve(target), })); if ( new Set(normalized.map(({ target }) => target)).size !== normalized.length ) throw new Error('Publication targets must be unique.'); for (const item of normalized) { if (!(await exists(item.staged))) throw new Error(`Staged publication input is missing: ${item.staged}`); if (!force && (await exists(item.target))) throw new Error(`Publication target already exists: ${item.target}`); } const backups = []; const published = []; try { for (const item of normalized) { if (!(await exists(item.target))) continue; const backup = backupPath(item.target); await renamePath(item.target, backup); backups.push({ target: item.target, backup }); } for (const item of normalized) { await renamePath(item.staged, item.target); published.push(item.target); } } catch (publicationError) { const rollbackErrors = []; for (const target of [...published].reverse()) { try { await removePath(target, { recursive: true, force: true }); } catch (error) { rollbackErrors.push(error); } } for (const item of [...backups].reverse()) { try { await renamePath(item.backup, item.target); } catch (error) { rollbackErrors.push(error); } } if (rollbackErrors.length > 0) { throw new AggregateError( [publicationError, ...rollbackErrors], 'Release publication failed and rollback was incomplete.' ); } throw publicationError; } for (const { backup } of backups) { await removePath(backup, { recursive: true, force: true }); } }