From 33bd288f9eaee2df1bfc4d0b5aef6040de4e22ed Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 7 Mar 2026 01:05:46 +0000 Subject: [PATCH] Initial project outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + bin/delta-backup.js | 22 ++++++++++++ lib/args.js | 53 +++++++++++++++++++++++++++++ lib/backends/index.js | 12 +++++++ lib/backends/zstd.js | 35 +++++++++++++++++++ lib/commands/run.js | 76 ++++++++++++++++++++++++++++++++++++++++++ lib/commands/status.js | 28 ++++++++++++++++ lib/config.js | 43 ++++++++++++++++++++++++ lib/spawn.js | 28 ++++++++++++++++ lib/state.js | 40 ++++++++++++++++++++++ package.json | 12 +++++++ 11 files changed, 350 insertions(+) create mode 100644 .gitignore create mode 100644 bin/delta-backup.js create mode 100644 lib/args.js create mode 100644 lib/backends/index.js create mode 100644 lib/backends/zstd.js create mode 100644 lib/commands/run.js create mode 100644 lib/commands/status.js create mode 100644 lib/config.js create mode 100644 lib/spawn.js create mode 100644 lib/state.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/bin/delta-backup.js b/bin/delta-backup.js new file mode 100644 index 0000000..59e4dfc --- /dev/null +++ b/bin/delta-backup.js @@ -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); diff --git a/lib/args.js b/lib/args.js new file mode 100644 index 0000000..057b71e --- /dev/null +++ b/lib/args.js @@ -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] + +Commands: + run Full backup run (clear PEND, rsync, delta, commit, promote) + status Show current state + +Options: + --source SOURCE directory (required) + --prev PREV directory (required) + --pend PEND directory (required) + --deltas DELTAS directory (required) + --backend Delta backend: zstd (default), xdelta3 + --config 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'] }; +} diff --git a/lib/backends/index.js b/lib/backends/index.js new file mode 100644 index 0000000..9e30b99 --- /dev/null +++ b/lib/backends/index.js @@ -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; +} diff --git a/lib/backends/zstd.js b/lib/backends/zstd.js new file mode 100644 index 0000000..f3c61d0 --- /dev/null +++ b/lib/backends/zstd.js @@ -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 }); + } +} diff --git a/lib/commands/run.js b/lib/commands/run.js new file mode 100644 index 0000000..6cfb90f --- /dev/null +++ b/lib/commands/run.js @@ -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 + '/'; +} diff --git a/lib/commands/status.js b/lib/commands/status.js new file mode 100644 index 0000000..6874699 --- /dev/null +++ b/lib/commands/status.js @@ -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(''); +} diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..d43adc3 --- /dev/null +++ b/lib/config.js @@ -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)); +} diff --git a/lib/spawn.js b/lib/spawn.js new file mode 100644 index 0000000..1174a14 --- /dev/null +++ b/lib/spawn.js @@ -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} + */ +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}`)); + }); + }); +} diff --git a/lib/state.js b/lib/state.js new file mode 100644 index 0000000..2378965 --- /dev/null +++ b/lib/state.js @@ -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', +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..de69de7 --- /dev/null +++ b/package.json @@ -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" + } +}