import { spawn } from 'node:child_process'; import { PassThrough, Transform } from 'node:stream'; import { resticProcessEnv } from './config.js'; export class CommandError extends Error { constructor(message, result) { super(message); this.name = 'CommandError'; this.result = result; } } export function spawnCommand(command, args = [], options = {}) { const { env = process.env, input = null, log = null, ignoreExitCode = false, cwd = process.cwd(), } = options; return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { const text = chunk.toString(); stdout += text; log?.(text.trimEnd()); }); child.stderr.on('data', (chunk) => { const text = chunk.toString(); stderr += text; log?.(text.trimEnd()); }); child.on('error', (error) => { reject(error); }); child.on('close', (exitCode) => { const result = { stdout, stderr, exitCode }; if (exitCode !== 0 && !ignoreExitCode) { reject(new CommandError(stderr.trim() || `${command} exited with ${exitCode}`, result)); return; } resolve(result); }); if (input) { input.on('error', (error) => { child.kill('SIGTERM'); reject(error); }); input.pipe(child.stdin); } else { child.stdin.end(); } }); } export function runRestic(args, options = {}) { return spawnCommand('restic', args, { ...options, env: resticProcessEnv(), }); } export function createProgressStream(totalBytes, onProgress) { let currentBytes = 0; return new Transform({ transform(chunk, _encoding, callback) { currentBytes += chunk.length; onProgress?.({ currentBytes, totalBytes, percent: totalBytes ? (currentBytes / totalBytes) * 100 : 0, }); callback(null, chunk); }, }); } export function streamResticDumpToDd(snapshotId, filename, outputPath, log, progress = null) { return new Promise((resolve, reject) => { const restic = spawn('restic', ['dump', snapshotId, filename], { env: resticProcessEnv(), shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); const dd = spawn('dd', [`of=${outputPath}`, 'bs=4M', 'conv=sparse', 'status=none'], { env: process.env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], }); const errors = []; let resticClosed = false; let ddClosed = false; const pipe = new PassThrough(); let restoreStream = restic.stdout.pipe(pipe); if (progress) { restoreStream = restoreStream.pipe(createProgressStream(progress.totalBytes, progress.onProgress)); } restoreStream.pipe(dd.stdin); restic.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd())); dd.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd())); restic.on('error', reject); dd.on('error', reject); restic.on('close', (code) => { resticClosed = true; if (code !== 0) { errors.push(`restic dump exited with ${code}`); dd.stdin.destroy(); } maybeFinish(); }); dd.on('close', (code) => { ddClosed = true; if (code !== 0) { errors.push(`dd exited with ${code}`); } maybeFinish(); }); function maybeFinish() { if (!resticClosed || !ddClosed) return; if (errors.length) { reject(new Error(errors.join('; '))); return; } resolve(); } }); }