diff --git a/src/cmd-backup.mjs b/src/cmd-backup.mjs index ee053b2..806ce7d 100644 --- a/src/cmd-backup.mjs +++ b/src/cmd-backup.mjs @@ -2,8 +2,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { execSync } from 'node:child_process'; -const BACKUP_ROOT = path.resolve(process.cwd(), '.torch/backups'); - // Paths to snapshot on each backup run const STATE_SOURCES = [ '.scheduler-memory/memory-store.json', @@ -22,19 +20,21 @@ const STATE_SOURCES = [ * * @param {Object} [opts] * @param {string|null} [opts.output] - Override destination directory (default: .torch/backups/) + * @param {string} [opts.cwd] - Working directory (default: process.cwd()) * @returns {Promise<{backupDir: string, manifest: Object}>} */ -export async function cmdBackup({ output = null } = {}) { +export async function cmdBackup({ output = null, cwd = process.cwd() } = {}) { + const BACKUP_ROOT = path.resolve(cwd, '.torch/backups'); const ts = new Date().toISOString().replace(/[:.]/g, '-'); const backupDir = output - ? path.resolve(process.cwd(), output) + ? path.resolve(cwd, output) : path.join(BACKUP_ROOT, ts); await fs.mkdir(backupDir, { recursive: true }); let gitCommit = null; try { - gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim(); + gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe', cwd }).toString().trim(); } catch { // Not in a git repo or git unavailable } @@ -43,7 +43,7 @@ export async function cmdBackup({ output = null } = {}) { const skipped = []; for (const relPath of STATE_SOURCES) { - const src = path.resolve(process.cwd(), relPath); + const src = path.resolve(cwd, relPath); const dest = path.join(backupDir, relPath.replace(/\//g, '__')); try { await fs.copyFile(src, dest); @@ -77,9 +77,12 @@ export async function cmdBackup({ output = null } = {}) { /** * Lists all available backups under .torch/backups/, newest first. * + * @param {Object} [opts] + * @param {string} [opts.cwd] - Working directory (default: process.cwd()) * @returns {Promise>} */ -export async function listBackups() { +export async function listBackups({ cwd = process.cwd() } = {}) { + const BACKUP_ROOT = path.resolve(cwd, '.torch/backups'); try { const entries = await fs.readdir(BACKUP_ROOT, { withFileTypes: true }); const backups = []; diff --git a/test/cmd-backup.test.mjs b/test/cmd-backup.test.mjs new file mode 100644 index 0000000..131bdb7 --- /dev/null +++ b/test/cmd-backup.test.mjs @@ -0,0 +1,99 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cmdBackup, listBackups } from '../src/cmd-backup.mjs'; + +const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-backup-')); + +// Helper to setup dummy state files +function setupStateFiles(cwd) { + const memoryDir = path.join(cwd, '.scheduler-memory'); + const logsDir = path.join(cwd, 'task-logs', 'daily'); + + fs.mkdirSync(memoryDir, { recursive: true }); + fs.mkdirSync(logsDir, { recursive: true }); + + fs.writeFileSync(path.join(memoryDir, 'memory-store.json'), '{"dummy": "memory"}'); + fs.writeFileSync(path.join(logsDir, '.scheduler-run-state.json'), '{"dummy": "state"}'); +} + +after(() => { + fs.rmSync(tempBase, { recursive: true, force: true }); +}); + +test('cmdBackup creates backup directory and manifest', async () => { + const cwd = path.join(tempBase, 'scenario-1'); + fs.mkdirSync(cwd, { recursive: true }); + setupStateFiles(cwd); + + const { backupDir } = await cmdBackup({ cwd }); + + assert.ok(fs.existsSync(backupDir), 'Backup directory created'); + assert.ok(fs.existsSync(path.join(backupDir, 'backup-manifest.json')), 'Manifest created'); + + const manifestContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'backup-manifest.json'), 'utf8')); + assert.strictEqual(manifestContent.backupDir, backupDir); + assert.ok(manifestContent.createdAt); + + // Verify files are copied + const backupMemory = path.join(backupDir, '.scheduler-memory__memory-store.json'); + assert.ok(fs.existsSync(backupMemory), 'Memory file copied'); + + // captured should contain the 2 files + assert.strictEqual(manifestContent.captured.length, 2); +}); + +test('cmdBackup handles missing files gracefully', async () => { + const cwd = path.join(tempBase, 'scenario-2'); + fs.mkdirSync(cwd, { recursive: true }); + // Do NOT setup files + + const { manifest } = await cmdBackup({ cwd }); + + assert.strictEqual(manifest.captured.length, 0); + assert.strictEqual(manifest.skipped.length, 2); + assert.strictEqual(manifest.skipped[0].reason, 'not found'); +}); + +test('cmdBackup respects output override', async () => { + const cwd = path.join(tempBase, 'scenario-3'); + fs.mkdirSync(cwd, { recursive: true }); + setupStateFiles(cwd); + + const outputDir = 'custom-backup-location'; + const { backupDir } = await cmdBackup({ cwd, output: outputDir }); + + const expectedPath = path.resolve(cwd, outputDir); + assert.strictEqual(backupDir, expectedPath); + assert.ok(fs.existsSync(expectedPath)); + assert.ok(fs.existsSync(path.join(expectedPath, 'backup-manifest.json')), 'Manifest in custom output'); +}); + +test('listBackups lists created backups', async () => { + const cwd = path.join(tempBase, 'scenario-4'); + fs.mkdirSync(cwd, { recursive: true }); + setupStateFiles(cwd); + + // Create two backups + await cmdBackup({ cwd }); + + // Ensure we have a slight delay or just rely on execution time for timestamp diff + // Windows/Filesystem might have low resolution, but listBackups sorts by ISO string in manifest + await new Promise(r => setTimeout(r, 100)); + + await cmdBackup({ cwd }); + + const backups = await listBackups({ cwd }); + assert.strictEqual(backups.length, 2); + + // Verify sorting (newest first) + const t0 = new Date(backups[0].createdAt).getTime(); + const t1 = new Date(backups[1].createdAt).getTime(); + assert.ok(t0 >= t1, 'Backups sorted newest first'); + + // Verify properties + assert.ok(backups[0].id); + assert.ok(backups[0].backupDir); + assert.ok(Array.isArray(backups[0].captured)); +});