Initial project outline
- package.json (ESM, bin entry) - bin/delta-backup.js — entrypoint - lib/args.js — CLI arg parsing via Node parseArgs - lib/config.js — config file merging + required path guards - lib/spawn.js — safe process spawning (no shell strings) - lib/state.js — sequence number + phase state management - lib/backends/zstd.js — zstd delta backend - lib/backends/index.js — backend registry - lib/commands/run.js — full run skeleton (phases 1-3 wired, 4-6 stubbed) - lib/commands/status.js — status command
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
22
bin/delta-backup.js
Normal file
22
bin/delta-backup.js
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgs } from '../lib/args.js';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { runCommand } from '../lib/commands/run.js';
|
||||
import { statusCommand } from '../lib/commands/status.js';
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const config = await loadConfig(args);
|
||||
|
||||
const commands = {
|
||||
run: runCommand,
|
||||
status: statusCommand,
|
||||
};
|
||||
|
||||
const cmd = commands[config.command];
|
||||
if (!cmd) {
|
||||
console.error(`Unknown command: ${config.command}`);
|
||||
console.error('Available commands: run, status');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await cmd(config);
|
||||
53
lib/args.js
Normal file
53
lib/args.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CLI argument parsing — no external deps, uses Node's parseArgs.
|
||||
*/
|
||||
import { parseArgs as nodeParseArgs } from 'util';
|
||||
|
||||
const HELP = `
|
||||
Usage: delta-backup [options] <command>
|
||||
|
||||
Commands:
|
||||
run Full backup run (clear PEND, rsync, delta, commit, promote)
|
||||
status Show current state
|
||||
|
||||
Options:
|
||||
--source <path> SOURCE directory (required)
|
||||
--prev <path> PREV directory (required)
|
||||
--pend <path> PEND directory (required)
|
||||
--deltas <path> DELTAS directory (required)
|
||||
--backend <name> Delta backend: zstd (default), xdelta3
|
||||
--config <file> Load options from JSON config file (flags override)
|
||||
--dry-run Print what would happen, execute nothing
|
||||
--help Show this help
|
||||
`.trim();
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const { values, positionals } = nodeParseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
source: { type: 'string' },
|
||||
prev: { type: 'string' },
|
||||
pend: { type: 'string' },
|
||||
deltas: { type: 'string' },
|
||||
backend: { type: 'string' },
|
||||
config: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = positionals[0];
|
||||
if (!command) {
|
||||
console.error('Error: command required (run, status)\n');
|
||||
console.error(HELP);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { ...values, command, dryRun: values['dry-run'] };
|
||||
}
|
||||
12
lib/backends/index.js
Normal file
12
lib/backends/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as zstd from './zstd.js';
|
||||
|
||||
const BACKENDS = { zstd };
|
||||
|
||||
export function getBackend(name) {
|
||||
const backend = BACKENDS[name];
|
||||
if (!backend) {
|
||||
console.error(`Unknown backend: ${name}. Available: ${Object.keys(BACKENDS).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
35
lib/backends/zstd.js
Normal file
35
lib/backends/zstd.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* zstd delta backend.
|
||||
*
|
||||
* Modified files: zstd --patch-from=prev new -o out.zst
|
||||
* New files: zstd new -o out.zst (no base, just compress)
|
||||
* Deleted files: no delta file, manifest entry only
|
||||
*/
|
||||
import { run } from '../spawn.js';
|
||||
|
||||
export const name = 'zstd';
|
||||
export const ext = '.zst';
|
||||
|
||||
/**
|
||||
* Create a delta from prevFile to newFile, output to outFile.
|
||||
* If prevFile is null, newFile is new — compress without a base.
|
||||
*/
|
||||
export async function createDelta(prevFile, newFile, outFile, { dryRun } = {}) {
|
||||
if (prevFile) {
|
||||
await run('zstd', ['--patch-from', prevFile, newFile, '-o', outFile, '-f'], { dryRun });
|
||||
} else {
|
||||
await run('zstd', [newFile, '-o', outFile, '-f'], { dryRun });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a delta on top of prevFile to produce outFile.
|
||||
* If prevFile is null, the delta is a plain compressed file.
|
||||
*/
|
||||
export async function applyDelta(prevFile, deltaFile, outFile, { dryRun } = {}) {
|
||||
if (prevFile) {
|
||||
await run('zstd', ['-d', '--patch-from', prevFile, deltaFile, '-o', outFile, '-f'], { dryRun });
|
||||
} else {
|
||||
await run('zstd', ['-d', deltaFile, '-o', outFile, '-f'], { dryRun });
|
||||
}
|
||||
}
|
||||
76
lib/commands/run.js
Normal file
76
lib/commands/run.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* run command — full backup run.
|
||||
*/
|
||||
import { rm, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { run as spawn } from '../spawn.js';
|
||||
import { getBackend } from '../backends/index.js';
|
||||
import { readState, writeState, PHASES } from '../state.js';
|
||||
|
||||
export async function runCommand(config) {
|
||||
const { source, prev, pend, deltas, backend: backendName, dryRun } = config;
|
||||
const backend = getBackend(backendName);
|
||||
const dry = dryRun;
|
||||
|
||||
if (dry) console.log('[dry-run] No changes will be made.\n');
|
||||
|
||||
// ── Load state ──────────────────────────────────────────────
|
||||
const state = await readState(deltas);
|
||||
const seq = state.next_seq;
|
||||
|
||||
console.log(`Starting run — seq ${seq} (last complete: ${state.last_complete})`);
|
||||
|
||||
// TODO: detect and handle partially-committed previous run
|
||||
|
||||
// ── Phase 1: Clear PEND ─────────────────────────────────────
|
||||
await setPhase(deltas, state, PHASES.CLEARING_PEND, dry);
|
||||
console.log('\n── Clear PEND ──');
|
||||
if (!dry) {
|
||||
await rm(pend, { recursive: true, force: true });
|
||||
await mkdir(pend, { recursive: true });
|
||||
} else {
|
||||
console.log(`[dry-run] rm -rf ${pend} && mkdir -p ${pend}`);
|
||||
}
|
||||
|
||||
// ── Phase 2: rsync PREV → PEND (local seed) ─────────────────
|
||||
await setPhase(deltas, state, PHASES.RSYNC_LOCAL, dry);
|
||||
console.log('\n── rsync PREV → PEND (local seed) ──');
|
||||
await spawn('rsync', ['-aP', trailingSlash(prev), pend], { dryRun: dry });
|
||||
|
||||
// ── Phase 3: rsync SOURCE → PEND (remote changes) ───────────
|
||||
await setPhase(deltas, state, PHASES.RSYNC_REMOTE, dry);
|
||||
console.log('\n── rsync SOURCE → PEND ──');
|
||||
await spawn('rsync', ['-aP', trailingSlash(source), pend], { dryRun: dry });
|
||||
|
||||
// ── Phase 4: Generate delta ──────────────────────────────────
|
||||
await setPhase(deltas, state, PHASES.GENERATING, dry);
|
||||
console.log('\n── Generate delta ──');
|
||||
// TODO: walk PREV and PEND, diff per file, build manifest
|
||||
|
||||
// ── Phase 5: Commit delta ────────────────────────────────────
|
||||
await setPhase(deltas, state, PHASES.COMMITTING, dry);
|
||||
console.log('\n── Commit delta ──');
|
||||
// TODO: atomic rename DELTAS/tmp/N → DELTAS/N
|
||||
|
||||
// ── Phase 6: Promote PEND → PREV ────────────────────────────
|
||||
await setPhase(deltas, state, PHASES.PROMOTING, dry);
|
||||
console.log('\n── Promote PEND → PREV ──');
|
||||
// TODO: mv PEND PREV (swap)
|
||||
|
||||
// ── Done ─────────────────────────────────────────────────────
|
||||
state.last_complete = seq;
|
||||
state.next_seq = seq + 1;
|
||||
state.phase = PHASES.IDLE;
|
||||
if (!dry) await writeState(deltas, state);
|
||||
|
||||
console.log(`\nRun complete — seq ${seq} committed.`);
|
||||
}
|
||||
|
||||
async function setPhase(deltas, state, phase, dry) {
|
||||
state.phase = phase;
|
||||
if (!dry) await writeState(deltas, state);
|
||||
}
|
||||
|
||||
function trailingSlash(p) {
|
||||
return p.endsWith('/') ? p : p + '/';
|
||||
}
|
||||
28
lib/commands/status.js
Normal file
28
lib/commands/status.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* status command — show current state.
|
||||
*/
|
||||
import { readState, PHASES } from '../state.js';
|
||||
|
||||
export async function statusCommand(config) {
|
||||
const { deltas } = config;
|
||||
|
||||
if (!deltas) {
|
||||
console.error('Error: --deltas required for status command');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const state = await readState(deltas);
|
||||
|
||||
console.log('\ndelta-backup status');
|
||||
console.log('='.repeat(40));
|
||||
console.log(` Last complete seq: ${state.last_complete}`);
|
||||
console.log(` Next seq: ${state.next_seq}`);
|
||||
console.log(` Current phase: ${state.phase}`);
|
||||
|
||||
if (state.phase !== PHASES.IDLE) {
|
||||
console.log('\n ⚠ Previous run did not complete cleanly.');
|
||||
console.log(' Run with same options to recover.');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
43
lib/config.js
Normal file
43
lib/config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Config loading — merges JSON config file with CLI args.
|
||||
* CLI args always win. Required paths are validated here.
|
||||
*/
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const REQUIRED_PATHS = ['source', 'prev', 'pend', 'deltas'];
|
||||
const DEFAULTS = {
|
||||
backend: 'zstd',
|
||||
};
|
||||
|
||||
export async function loadConfig(args) {
|
||||
let fileConfig = {};
|
||||
|
||||
if (args.config) {
|
||||
try {
|
||||
const raw = await readFile(args.config, 'utf8');
|
||||
fileConfig = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`Error reading config file ${args.config}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI args override file config, file config overrides defaults
|
||||
const config = { ...DEFAULTS, ...fileConfig, ...filterDefined(args) };
|
||||
|
||||
// Guard: refuse to run if any required path is missing
|
||||
if (config.command === 'run') {
|
||||
const missing = REQUIRED_PATHS.filter(k => !config[k]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`Error: missing required options: ${missing.map(k => `--${k}`).join(', ')}`);
|
||||
console.error('Provide them as CLI flags or in a --config JSON file.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function filterDefined(obj) {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
||||
}
|
||||
28
lib/spawn.js
Normal file
28
lib/spawn.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Process spawning — always explicit argument arrays, never shell strings.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Spawn a process and stream its output.
|
||||
* @param {string} cmd
|
||||
* @param {string[]} args
|
||||
* @param {{ dryRun?: boolean, label?: string }} opts
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function run(cmd, args, { dryRun = false, label } = {}) {
|
||||
const display = [cmd, ...args].join(' ');
|
||||
if (label) console.log(`[${label}] ${display}`);
|
||||
else console.log(`$ ${display}`);
|
||||
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
40
lib/state.js
Normal file
40
lib/state.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* State management — tracks sequence numbers and run phases in DELTAS/state.json.
|
||||
* The state file is a recoverable cache; source of truth is the DELTAS directory.
|
||||
*/
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
const STATE_FILE = 'state.json';
|
||||
|
||||
export async function readState(deltasDir) {
|
||||
const path = join(deltasDir, STATE_FILE);
|
||||
try {
|
||||
const raw = await readFile(path, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// Missing or unreadable — reconstruct from directory scan
|
||||
return reconstructState(deltasDir);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeState(deltasDir, state) {
|
||||
const path = join(deltasDir, STATE_FILE);
|
||||
await writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
async function reconstructState(deltasDir) {
|
||||
// TODO: scan DELTAS for highest committed sequence number
|
||||
// For now, start fresh
|
||||
return { next_seq: 1, last_complete: 0, phase: 'idle' };
|
||||
}
|
||||
|
||||
export const PHASES = {
|
||||
IDLE: 'idle',
|
||||
CLEARING_PEND: 'clearing_pend',
|
||||
RSYNC_LOCAL: 'rsync_local',
|
||||
RSYNC_REMOTE: 'rsync_remote',
|
||||
GENERATING: 'generating_delta',
|
||||
COMMITTING: 'committing',
|
||||
PROMOTING: 'promoting_prev',
|
||||
};
|
||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "delta-backup",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Space-efficient directory backups using binary deltas",
|
||||
"bin": {
|
||||
"delta-backup": "./bin/delta-backup.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node bin/delta-backup.js"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user