UNPKG

@oletizi/sampler-backup

Version:

Akai sampler backup utilities for hardware samplers via PiSCSI

558 lines (548 loc) • 21.7 kB
#!/usr/bin/env node import { Command } from "commander"; import { U as UserCancelledError, R as RemoteSource, L as LocalSource, D as DeviceResolver, A as AutoDetectBackup, I as InteractivePrompt } from "../interactive-prompt-BEay0YJ3.js"; import { loadConfig, getEnabledBackupSources, saveConfig } from "@oletizi/audiotools-config"; import { createDeviceDetector } from "@oletizi/lib-device-uuid"; import { homedir } from "os"; import { join } from "pathe"; import { readdir, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; const version = "1.0.0-alpha.42"; const packageJson = { version }; const program = new Command(); const DEFAULT_BACKUP_ROOT = join(homedir(), ".audiotools", "backup"); function formatBytes(bytes) { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const k = 1024; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); return `${value.toFixed(2)} ${units[i]}`; } async function getDirectorySize(dirPath) { let totalSize = 0; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { totalSize += await getDirectorySize(fullPath); } else if (entry.isFile()) { const stats = await stat(fullPath); totalSize += stats.size; } } } catch (error) { console.error(`Error reading directory ${dirPath}: ${error.message}`); } return totalSize; } async function countFiles(dirPath) { let fileCount = 0; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { fileCount += await countFiles(fullPath); } else if (entry.isFile()) { fileCount++; } } } catch (error) { console.error(`Error reading directory ${dirPath}: ${error.message}`); } return fileCount; } function isRemotePath(path) { const hasColon = path.includes(":"); const isWindowsPath = /^[A-Za-z]:/.test(path); return hasColon && !isWindowsPath; } function isLocalMountPath(path) { if (path.startsWith("/")) { return true; } if (path.includes("/Volumes/")) { return true; } if (path.includes("/mnt/")) { return true; } if (/^[A-Za-z]:[\\\/]/.test(path)) { return true; } return false; } function parseRemotePath(path) { const colonIndex = path.indexOf(":"); if (colonIndex === -1) { throw new Error(`Invalid remote path format: ${path}`); } const hostPart = path.substring(0, colonIndex); const sourcePath = path.substring(colonIndex + 1); if (!hostPart || !sourcePath) { throw new Error(`Invalid remote path format: ${path}. Expected format: host:/path or user@host:/path`); } return { host: hostPart, sourcePath }; } function samplerNameFromHost(host) { const cleaned = host.replace(/\.local$/, ""); const withoutUser = cleaned.includes("@") ? cleaned.split("@")[1] : cleaned; return withoutUser.replace(/[.\/]/g, "-"); } function createSourceFromConfig(sourceConfig) { if (sourceConfig.type === "remote") { const config = { type: "remote", host: sourceConfig.source.split(":")[0], sourcePath: sourceConfig.source.split(":")[1] || "~/", device: sourceConfig.device, sampler: sourceConfig.sampler || samplerNameFromHost(sourceConfig.source.split(":")[0]) }; return new RemoteSource(config); } else { const config = { type: "local", sourcePath: sourceConfig.source, sampler: sourceConfig.sampler || "unknown", device: sourceConfig.device, backupSubdir: sourceConfig.sampler || "unknown" }; return new LocalSource(config); } } async function resolveLocalDevice(sourceConfig, config, dryRun = false) { if (sourceConfig.type !== "local") { return sourceConfig; } if (dryRun) { console.log(`[DRY RUN] Would resolve device UUID for ${sourceConfig.name}`); return sourceConfig; } const resolver = new DeviceResolver(); try { const result = await resolver.resolveDevice( sourceConfig.source, // mount path sourceConfig.name, // source name config ); if (result.action === "registered") { console.log(`šŸ“ ${result.message}`); } else if (result.action === "recognized") { console.log(`āœ“ ${result.message}`); } return result.source; } catch (error) { console.warn(`āš ļø Device UUID resolution failed: ${error.message}`); console.warn(` Continuing backup without UUID tracking`); return sourceConfig; } } async function autoDetectLocalDevice(mountPath, config, options) { if (options.dryRun) { console.log(`[DRY RUN] Would auto-detect device at ${mountPath}`); return { name: "dry-run-source", type: "local", source: mountPath, device: options.device || "unknown", sampler: options.sampler || "unknown", enabled: true }; } const deviceDetector = createDeviceDetector(); const promptService = new InteractivePrompt(); const deviceResolver = new DeviceResolver(); const autoDetect = new AutoDetectBackup(deviceDetector, promptService, deviceResolver); console.log(`Auto-detecting device at ${mountPath}...`); const result = await autoDetect.detectAndResolve(mountPath, config, { deviceType: options.device, sampler: options.sampler }); console.log(""); console.log("Device detected:"); if (result.deviceInfo.volumeLabel) { console.log(` Label: ${result.deviceInfo.volumeLabel}`); } if (result.deviceInfo.volumeUUID) { console.log(` UUID: ${result.deviceInfo.volumeUUID}`); } if (result.deviceInfo.filesystem) { console.log(` Filesystem: ${result.deviceInfo.filesystem}`); } console.log(""); if (result.action === "registered") { console.log(`šŸ“ Registered new backup source: ${result.source.name}`); } else if (result.action === "recognized") { console.log(`āœ“ Recognized existing backup source: ${result.source.name}`); } else { console.log(`āž• Created backup source: ${result.source.name} (no UUID tracking)`); } if (result.action === "registered" || result.action === "created") { if (!config.backup) { config.backup = { backupRoot: DEFAULT_BACKUP_ROOT, sources: [] }; } if (!config.backup.sources) { config.backup.sources = []; } config.backup.sources.push(result.source); await saveConfig(config); console.log("āœ“ Configuration saved"); } else if (result.action === "recognized") { const sourceIndex = config.backup?.sources?.findIndex( (s) => s.name === result.source.name ); if (sourceIndex !== void 0 && sourceIndex !== -1 && config.backup?.sources) { config.backup.sources[sourceIndex] = result.source; await saveConfig(config); console.log("āœ“ Configuration updated"); } } console.log(""); return result.source; } async function backupSource(source, dryRun = false) { console.log(`Syncing to: ${source.getBackupPath()}`); console.log(""); if (dryRun) { console.log("Mode: DRY RUN (no changes will be made)"); return; } const result = await source.backup("daily"); if (!result.success) { console.error("\nSync failed:"); result.errors.forEach((err) => console.error(` - ${err}`)); throw new Error("Backup failed"); } console.log("\nāœ“ Sync complete"); } async function handleSyncCommand(options) { try { const isRemote = isRemotePath(options.source); if (!isRemote && !options.sampler) { console.error("Error: --sampler is required for local sources"); console.error("Example: --sampler s5k-studio"); process.exit(1); } console.log(`Source: ${options.source}`); console.log(`Device: ${options.device}`); if (options.sampler) { console.log(`Sampler: ${options.sampler}`); } if (options.dryRun) { console.log("Mode: DRY RUN (no changes will be made)"); } console.log(""); let source; if (isRemote) { const { host, sourcePath } = parseRemotePath(options.source); const sampler = options.sampler ?? samplerNameFromHost(host); const config = { type: "remote", host, sourcePath, device: options.device, sampler }; source = new RemoteSource(config); console.log(`Sampler: ${sampler} (from hostname)`); } else { const config = { type: "local", sourcePath: options.source, sampler: options.sampler, device: options.device, backupSubdir: options.sampler // Deprecated but required }; source = new LocalSource(config); } await backupSource(source, options.dryRun); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } } async function handleBackupCommand(sourceName, options) { try { const config = await loadConfig(); if (sourceName && isLocalMountPath(sourceName)) { const mountPath = sourceName; try { const resolvedSource = await autoDetectLocalDevice(mountPath, config, { device: options.device, sampler: options.sampler, dryRun: options.dryRun }); const source = createSourceFromConfig(resolvedSource); await backupSource(source, options.dryRun); return; } catch (error) { if (error instanceof UserCancelledError) { console.log("\nBackup cancelled by user"); process.exit(0); } throw error; } } if (options.source) { return handleSyncCommand(options); } if (!config.backup) { console.error("Error: No backup configuration found"); console.error("Run 'audiotools config' or 'akai-backup config' to set up configuration"); process.exit(1); } const enabledSources = getEnabledBackupSources(config); if (enabledSources.length === 0) { console.error("Error: No enabled backup sources found in configuration"); console.error("Run 'audiotools config' or 'akai-backup config' to add backup sources"); process.exit(1); } if (sourceName) { let sourceConfig = enabledSources.find((s) => s.name === sourceName); if (!sourceConfig) { console.error(`Error: Source '${sourceName}' not found or not enabled in configuration`); console.error(`Available sources: ${enabledSources.map((s) => s.name).join(", ")}`); process.exit(1); } console.log(`Backing up source: ${sourceConfig.name}`); console.log(`Type: ${sourceConfig.type}`); console.log(`Source: ${sourceConfig.source}`); console.log(`Device: ${sourceConfig.device}`); console.log(""); const updatedSource = await resolveLocalDevice(sourceConfig, config, options.dryRun); if (updatedSource !== sourceConfig) { if (config.backup?.sources) { const sourceIndex = config.backup.sources.findIndex((s) => s.name === sourceName); if (sourceIndex !== -1) { config.backup.sources[sourceIndex] = updatedSource; await saveConfig(config); console.log(`āœ“ Configuration updated `); } } sourceConfig = updatedSource; } const source = createSourceFromConfig(sourceConfig); await backupSource(source, options.dryRun); return; } console.log(`Found ${enabledSources.length} enabled backup source(s) `); for (let i = 0; i < enabledSources.length; i++) { let sourceConfig = enabledSources[i]; console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(`[${i + 1}/${enabledSources.length}] ${sourceConfig.name}`); console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(`Type: ${sourceConfig.type}`); console.log(`Source: ${sourceConfig.source}`); console.log(`Device: ${sourceConfig.device}`); console.log(""); try { const updatedSource = await resolveLocalDevice(sourceConfig, config, options.dryRun); if (updatedSource !== sourceConfig) { if (config.backup?.sources) { const sourceIndex = config.backup.sources.findIndex((s) => s.name === sourceConfig.name); if (sourceIndex !== -1) { config.backup.sources[sourceIndex] = updatedSource; await saveConfig(config); console.log(`āœ“ Configuration updated `); } } sourceConfig = updatedSource; } const source = createSourceFromConfig(sourceConfig); await backupSource(source, options.dryRun); } catch (err) { console.error(`Failed to backup ${sourceConfig.name}: ${err.message}`); } console.log(""); } console.log("āœ“ All backups complete"); } catch (err) { if (err instanceof UserCancelledError) { console.log("\nBackup cancelled by user"); process.exit(0); } console.error(`Error: ${err.message}`); process.exit(1); } } program.name("akai-backup").description("Backup Akai sampler disk images using rsync").version(packageJson.version).addHelpText("after", ` Examples: Auto-detect backup (NEW): $ akai-backup backup /Volumes/SDCARD # Auto-detect device, prompt for details $ akai-backup backup /Volumes/SDCARD --device floppy # Auto-detect, specify device $ akai-backup backup /Volumes/SDCARD --device floppy --sampler s5000 # No prompts $ akai-backup backup /mnt/usb/sdcard --dry-run # Preview auto-detect Config-based backup: $ akai-backup backup # Backup all enabled sources from config $ akai-backup backup pi-scsi2 # Backup specific source by name $ akai-backup backup --dry-run # Preview changes without backing up Device UUID Tracking (Automatic): Local sources are automatically tracked by device UUID. When you backup a local device (SD card, USB drive), the system: - Detects the device UUID on first backup (registration) - Recognizes the same device on subsequent backups (recognition) - Updates the lastSeen timestamp each time - Works even if the mount path changes between backups Flag-based backup (backward compatible): Remote (SSH) backup - PiSCSI: $ akai-backup sync --source pi-scsi2.local:~/images/ --device images $ akai-backup sync --source pi@host:/data/images --device images Local media backup: $ akai-backup sync --source /Volumes/DSK0 --sampler s5k-studio --device floppy $ akai-backup sync --source /Volumes/GOTEK --sampler s3k-zulu --device floppy List synced backups: $ akai-backup list --all $ akai-backup list --sampler pi-scsi2 $ akai-backup list --sampler s5k-studio --device floppy Directory Structure: ~/.audiotools/backup/ ā”œā”€ā”€ pi-scsi2/ # Remote sampler (hostname) │ └── images/ # PiSCSI disk images directory │ ā”œā”€ā”€ HD0.hds │ ā”œā”€ā”€ HD1.hds │ └── akai.img └── s5k-studio/ # Local sampler (via --sampler) └── floppy/ # Floppy emulator └── DSK0.img Configuration: Run 'audiotools config' or 'akai-backup config' to set up backup sources. Config file: ~/.audiotools/config.json Requirements: - rsync: Installed by default on macOS and most Linux systems - SSH access to remote hosts (passwordless keys recommended) How It Works: Simple file-level synchronization using rsync. Only changed files are transferred. Files are organized hierarchically by sampler and device. No snapshots, no archives - just fast, simple sync. `); program.command("backup [source]").description("Backup from config, auto-detect device, or by source name").option("-s, --source <path>", "Override: use flag-based source path instead of config").option("-d, --device <name>", "Device type (for auto-detect or --source)").option("--sampler <name>", "Sampler name (for auto-detect or --source)").option("--dry-run", "Show what would be synced without actually syncing").action(handleBackupCommand); program.command("sync").description("Sync sampler disk images from remote or local source (flag-based)").requiredOption("-s, --source <path>", "Source path (local or remote SSH)").requiredOption("-d, --device <name>", "Device name (scsi0, scsi1, floppy, etc.)").option("--sampler <name>", "Sampler name (required for local sources, optional for remote)").option("--dry-run", "Show what would be synced without actually syncing").action(handleSyncCommand); program.command("list").description("List all synced backups").option("-a, --all", "List all samplers and devices").option("-s, --sampler <name>", "List specific sampler").option("-d, --device <name>", "List specific device (requires --sampler)").option("--json", "Output in JSON format").action(async (options) => { try { if (!existsSync(DEFAULT_BACKUP_ROOT)) { console.log("No backups found."); console.log(`Backup directory does not exist: ${DEFAULT_BACKUP_ROOT}`); return; } if (options.device && !options.sampler) { console.error("Error: --device requires --sampler"); process.exit(1); } const data = {}; if (options.sampler && options.device) { const devicePath = join(DEFAULT_BACKUP_ROOT, options.sampler, options.device); if (!existsSync(devicePath)) { console.log(`No backup found for ${options.sampler}/${options.device}`); return; } const fileCount = await countFiles(devicePath); const totalSize = await getDirectorySize(devicePath); if (options.json) { data[options.sampler] = { [options.device]: { path: devicePath, files: fileCount, size: totalSize, sizeFormatted: formatBytes(totalSize) } }; console.log(JSON.stringify(data, null, 2)); } else { console.log(`${options.sampler}/${options.device}/`); console.log(` Path: ${devicePath}`); console.log(` Files: ${fileCount}`); console.log(` Size: ${formatBytes(totalSize)}`); } } else if (options.sampler) { const samplerPath = join(DEFAULT_BACKUP_ROOT, options.sampler); if (!existsSync(samplerPath)) { console.log(`No backup found for sampler: ${options.sampler}`); return; } const devices = await readdir(samplerPath, { withFileTypes: true }); const deviceDirs = devices.filter((d) => d.isDirectory()); if (deviceDirs.length === 0) { console.log(`No devices found for sampler: ${options.sampler}`); return; } data[options.sampler] = {}; for (const device of deviceDirs) { const devicePath = join(samplerPath, device.name); const fileCount = await countFiles(devicePath); const totalSize = await getDirectorySize(devicePath); data[options.sampler][device.name] = { path: devicePath, files: fileCount, size: totalSize, sizeFormatted: formatBytes(totalSize) }; } if (options.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`${options.sampler}/`); for (const device of deviceDirs) { const info = data[options.sampler][device.name]; console.log(` ${device.name}/ (${info.files} files, ${info.sizeFormatted})`); } } } else { const samplers = await readdir(DEFAULT_BACKUP_ROOT, { withFileTypes: true }); const samplerDirs = samplers.filter((d) => d.isDirectory()); if (samplerDirs.length === 0) { console.log("No backups found."); return; } for (const sampler of samplerDirs) { const samplerPath = join(DEFAULT_BACKUP_ROOT, sampler.name); const devices = await readdir(samplerPath, { withFileTypes: true }); const deviceDirs = devices.filter((d) => d.isDirectory()); data[sampler.name] = {}; for (const device of deviceDirs) { const devicePath = join(samplerPath, device.name); const fileCount = await countFiles(devicePath); const totalSize = await getDirectorySize(devicePath); data[sampler.name][device.name] = { path: devicePath, files: fileCount, size: totalSize, sizeFormatted: formatBytes(totalSize) }; } } if (options.json) { console.log(JSON.stringify(data, null, 2)); } else { console.log(`${DEFAULT_BACKUP_ROOT}/`); for (const sampler of samplerDirs) { console.log(`ā”œā”€ā”€ ${sampler.name}/`); const devices = Object.keys(data[sampler.name]); devices.forEach((device, idx) => { const isLast = idx === devices.length - 1; const prefix = isLast ? "└──" : "ā”œā”€ā”€"; const info = data[sampler.name][device]; console.log(`│ ${prefix} ${device}/ (${info.files} files, ${info.sizeFormatted})`); }); } } } } catch (err) { console.error(`Error listing backups: ${err.message}`); process.exit(1); } }); program.parse(); //# sourceMappingURL=backup.js.map