Add rsync exit code awareness + plan operation abstraction

- 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
This commit is contained in:
2026-03-07 01:41:25 +00:00
parent ab7479e62d
commit 45924cbcd7
3 changed files with 83 additions and 17 deletions

View File

@@ -29,9 +29,10 @@ export async function run(cmd, args, { dryRun = false } = {}) {
* 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) {
export async function capture(cmd, args, { allowedExitCodes = [0] } = {}) {
console.log(`$ ${[cmd, ...args].join(' ')}`);
return new Promise((resolve, reject) => {
@@ -40,8 +41,45 @@ export async function capture(cmd, args) {
child.stdout.on('data', chunk => chunks.push(chunk));
child.on('error', reject);
child.on('close', code => {
if (code === 0) resolve(Buffer.concat(chunks).toString('utf8'));
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}`));
}
});
});
}