import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); /** * The uploads and plugins directories travel in one tar archive per restore * set (ADR 0015). Both must share a parent directory (in the compose stack * that is `/data`): entries are stored relative to it (`uploads/…`, * `plugins/…`), so extraction lands exactly where the api reads. */ export function archiveBase(dataDirs: string[]): { base: string; names: string[] } { const bases = new Set(dataDirs.map((dir) => dirname(dir))); if (bases.size !== 1) { throw new Error( `data directories must share one parent to form a single archive, got: ${dataDirs.join(', ')}`, ); } return { base: [...bases][0]!, names: dataDirs.map((dir) => basename(dir)) }; } async function runTar(args: string[]): Promise { try { await execFileAsync('tar', args, { maxBuffer: 16 * 1024 * 1024 }); } catch (error) { const stderr = (error as { stderr?: string }).stderr?.trim(); throw new Error(`tar failed: ${stderr || (error as Error).message}`); } } /** Creates the gzip'd volume archive; directories that do not exist yet * (fresh instance without uploads) are skipped, an empty set still produces * a valid empty archive. */ export async function createArchive(outFile: string, dataDirs: string[]): Promise { const { base, names } = archiveBase(dataDirs); const existing = names.filter((name) => existsSync(`${base}/${name}`)); if (existing.length === 0) { await runTar(['-czf', outFile, '--files-from', '/dev/null']); return; } await runTar(['-czf', outFile, '-C', base, ...existing]); } /** Unpacks a volume archive back over the data directories (restore path). */ export async function extractArchive(archiveFile: string, dataDirs: string[]): Promise { const { base } = archiveBase(dataDirs); await extractArchiveTo(archiveFile, base); } /** Unpacks any of our tar.gz archives into a destination directory. */ export async function extractArchiveTo(archiveFile: string, destDir: string): Promise { await runTar(['-xzf', archiveFile, '-C', destDir]); } /** * Packs individual files (relative to `baseDir`, stored flat) into a gzip'd * tar — the remote bundle format of issue #103. */ export async function createArchiveOfFiles( outFile: string, baseDir: string, fileNames: string[], ): Promise { await runTar(['-czf', outFile, '-C', baseDir, ...fileNames]); }