- spawn.js: rsync() wrapper handles exit codes 0/24 as OK, 23 as fatal - spawn.js: capture() accepts allowedExitCodes option - run.js: all rsync calls go through rsync() wrapper - PLAN.md: document planned operation abstraction refactor
86 lines
2.7 KiB
JavaScript
86 lines
2.7 KiB
JavaScript
/**
|
|
* Process spawning — always explicit argument arrays, never shell strings.
|
|
*/
|
|
import { spawn } from 'child_process';
|
|
|
|
/**
|
|
* Spawn a process and stream its output to stdout/stderr.
|
|
* @param {string} cmd
|
|
* @param {string[]} args
|
|
* @param {{ dryRun?: boolean }} opts
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function run(cmd, args, { dryRun = false } = {}) {
|
|
console.log(`$ ${[cmd, ...args].join(' ')}`);
|
|
if (dryRun) return;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
child.on('error', reject);
|
|
child.on('close', code => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`${cmd} exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Spawn a process and capture stdout as a string.
|
|
* stderr is inherited (shown to user). Never used in dry-run context.
|
|
* @param {string} cmd
|
|
* @param {string[]} args
|
|
* @param {{ allowedExitCodes?: number[] }} opts
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function capture(cmd, args, { allowedExitCodes = [0] } = {}) {
|
|
console.log(`$ ${[cmd, ...args].join(' ')}`);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] });
|
|
const chunks = [];
|
|
child.stdout.on('data', chunk => chunks.push(chunk));
|
|
child.on('error', reject);
|
|
child.on('close', code => {
|
|
if (allowedExitCodes.includes(code)) resolve(Buffer.concat(chunks).toString('utf8'));
|
|
else reject(new Error(`${cmd} exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
// rsync exit codes that are not errors
|
|
const RSYNC_OK_CODES = [
|
|
0, // success
|
|
24, // partial transfer: source files vanished mid-run (acceptable)
|
|
];
|
|
|
|
const RSYNC_ERROR_CODES = {
|
|
23: 'partial transfer due to error',
|
|
};
|
|
|
|
/**
|
|
* Run rsync with exit code awareness.
|
|
* @param {string[]} args
|
|
* @param {{ dryRun?: boolean, capture?: boolean }} opts
|
|
* @returns {Promise<void | string>}
|
|
*/
|
|
export async function rsync(args, { dryRun = false, capture: doCapture = false } = {}) {
|
|
console.log(`$ rsync ${args.join(' ')}`);
|
|
if (dryRun) return doCapture ? '' : undefined;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const stdio = doCapture ? ['inherit', 'pipe', 'inherit'] : 'inherit';
|
|
const child = spawn('rsync', args, { stdio });
|
|
const chunks = [];
|
|
if (doCapture) child.stdout.on('data', chunk => chunks.push(chunk));
|
|
child.on('error', reject);
|
|
child.on('close', code => {
|
|
if (RSYNC_OK_CODES.includes(code)) {
|
|
resolve(doCapture ? Buffer.concat(chunks).toString('utf8') : undefined);
|
|
} else {
|
|
const reason = RSYNC_ERROR_CODES[code] ?? `unknown error`;
|
|
reject(new Error(`rsync exited with code ${code}: ${reason}`));
|
|
}
|
|
});
|
|
});
|
|
}
|