UNPKG

@oletizi/sampler-backup

Version:

Akai sampler backup utilities for hardware samplers via PiSCSI

774 lines (773 loc) 23.5 kB
import { spawn } from "node:child_process"; import { mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { createDeviceDetector } from "@oletizi/lib-device-uuid"; import inquirer from "inquirer"; class RsyncAdapter { /** * Synchronize files from source to destination using rsync. * * Uses rsync's archive mode (-a) to preserve permissions, timestamps, etc. * Automatically deletes files in destination that don't exist in source. * * @param config - Rsync configuration * @throws {Error} If rsync command fails or is not available * * @example * ```typescript * await adapter.sync({ * sourcePath: 'pi-scsi2:/home/orion/images/', * destPath: '~/.audiotools/backup/pi-scsi2/scsi0/', * dryRun: true, // Preview changes first * }); * ``` */ async sync(config) { const args = [ "-av", // Archive mode, verbose "--delete", // Delete files that don't exist in source "--progress" // Show progress ]; if (config.dryRun) { args.push("--dry-run"); } args.push(config.sourcePath); args.push(config.destPath); return new Promise((resolve, reject) => { const rsync = spawn("rsync", args, { stdio: "inherit" }); rsync.on("close", (code) => { if (code === 0) { resolve(); } else if (code === 23 || code === 24) { console.warn(` ⚠️ Partial transfer (rsync code ${code}): Some files could not be transferred`); console.warn(" Common causes: Permission denied on system files (.Spotlight-V100, .Trashes)"); console.warn(" Your data files were backed up successfully.\n"); resolve(); } else { reject(new Error(`rsync failed with code ${code}`)); } }); rsync.on("error", (err) => { reject(new Error(`Failed to execute rsync: ${err.message}`)); }); }); } /** * Check if rsync is available on the system. * * Attempts to execute `rsync --version` to verify availability. * * @returns Promise that resolves to true if rsync is available, false otherwise * * @example * ```typescript * const available = await adapter.checkRsyncAvailable(); * if (!available) { * throw new Error('rsync is not installed. Please install rsync to continue.'); * } * ``` */ async checkRsyncAvailable() { return new Promise((resolve) => { const check = spawn("rsync", ["--version"], { stdio: "ignore" }); check.on("close", (code) => { resolve(code === 0); }); check.on("error", () => { resolve(false); }); }); } } function resolveRepositoryPath(config) { const baseDir = join(homedir(), ".audiotools", "backup"); if (!config.device) { throw new Error("Device name is required (--device scsi0|scsi1|floppy|...)"); } const samplerName = resolveSamplerName(config); return join(baseDir, samplerName, config.device); } function resolveSamplerName(config) { if (config.sourceType === "remote") { if (config.sampler) { return sanitizeSamplerName(config.sampler); } if (!config.host) { throw new Error("Host is required for remote sources"); } return sanitizeSamplerName(config.host); } else { if (!config.sampler) { throw new Error( "Sampler name is required for local sources (use --sampler flag)\nExample: --sampler s5k-studio --device floppy" ); } return sanitizeSamplerName(config.sampler); } } function sanitizeSamplerName(name) { return name.replace(/\.local$/i, "").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); } class RemoteSource { constructor(config, rsyncAdapter) { this.config = config; this.type = "remote"; if (!config.device) { throw new Error("Device name is required (e.g., scsi0, scsi1, floppy)"); } this.rsyncAdapter = rsyncAdapter ?? new RsyncAdapter(); this.backupPath = resolveRepositoryPath({ sourceType: "remote", host: this.config.host, sampler: this.config.sampler, // Optional override device: this.config.device // Required }); } /** * Execute remote backup using rsync * * Simple synchronization: user@host:/path → ~/.audiotools/backup/{sampler}/{device}/ */ async backup(interval) { const result = { success: false, interval, configPath: "", // Not used with rsync errors: [] }; try { await mkdir(this.backupPath, { recursive: true }); const source = `${this.config.host}:${this.config.sourcePath}`; console.log(`Syncing ${source}${this.backupPath}`); await this.rsyncAdapter.sync({ sourcePath: source, destPath: this.backupPath }); console.log(`✓ Backup complete: ${this.backupPath}`); result.success = true; result.snapshotPath = this.backupPath; } catch (error) { const errorMessage = `Remote backup failed for ${this.config.host}: ${error.message}`; console.error(errorMessage); result.errors.push(errorMessage); } return result; } /** * Test if remote source is accessible * * Tests SSH connectivity to remote host. */ async test() { try { console.log(`Testing SSH connection to ${this.config.host}...`); const connected = await new Promise((resolve) => { const ssh = spawn("ssh", [this.config.host, "echo", "ok"]); ssh.on("close", (code) => { resolve(code === 0); }); ssh.on("error", () => { resolve(false); }); }); if (connected) { console.log(`✓ Successfully connected to ${this.config.host}`); } else { console.error(`✗ Failed to connect to ${this.config.host}`); } return connected; } catch (error) { console.error(`Remote source test failed: ${error.message}`); return false; } } /** * Get source configuration */ getConfig() { return this.config; } /** * Get the backup path for this source */ getBackupPath() { return this.backupPath; } } class LocalSource { constructor(config, rsyncAdapter) { this.config = config; this.type = "local"; this.rsyncAdapter = rsyncAdapter ?? new RsyncAdapter(); this.backupPath = resolveRepositoryPath({ sourceType: "local", sampler: this.config.sampler, device: this.config.device }); } /** * Execute local backup using rsync */ async backup(interval) { const result = { success: false, interval, configPath: "", // Not used with rsync errors: [] }; try { await mkdir(this.backupPath, { recursive: true }); console.log(`Syncing ${this.config.sourcePath} to ${this.backupPath}...`); await this.rsyncAdapter.sync({ sourcePath: this.config.sourcePath, destPath: this.backupPath }); console.log("\nBackup complete"); result.success = true; result.snapshotPath = this.backupPath; } catch (error) { const errorMessage = `Local backup failed for ${this.config.sourcePath}: ${error.message}`; console.error(errorMessage); result.errors.push(errorMessage); } return result; } /** * Test if local source is accessible */ async test() { return existsSync(this.config.sourcePath); } /** * Get source configuration */ getConfig() { return this.config; } /** * Get backup path for this source */ getBackupPath() { return this.backupPath; } } class DeviceMatcher { constructor(detector) { this.detector = detector ?? createDeviceDetector(); } /** * Attempts to match a mount path to an existing backup source by UUID * * @param mountPath - Path where device is mounted * @param sources - Array of configured backup sources * @returns Match result with device info and matched source (if any) */ async matchDevice(mountPath, sources) { const deviceInfo = await this.detector.detectDevice(mountPath); if (deviceInfo.volumeUUID) { const uuidMatches = sources.filter((s) => s.volumeUUID === deviceInfo.volumeUUID); if (uuidMatches.length === 1) { return { matched: true, source: uuidMatches[0], deviceInfo, reason: "uuid" }; } if (uuidMatches.length > 1) { return { matched: false, deviceInfo, reason: "conflict", conflictingSources: uuidMatches }; } } if (deviceInfo.volumeSerial) { const serialMatches = sources.filter( (s) => s.volumeSerial === deviceInfo.volumeSerial && !s.volumeUUID ); if (serialMatches.length === 1) { return { matched: true, source: serialMatches[0], deviceInfo, reason: "serial" }; } if (serialMatches.length > 1) { return { matched: false, deviceInfo, reason: "conflict", conflictingSources: serialMatches }; } } return { matched: false, deviceInfo, reason: "not-found" }; } /** * Registers a new device by populating UUID fields in a backup source * * @param source - Backup source to register * @param deviceInfo - Device information from detection * @returns Updated backup source with UUID fields populated */ registerDevice(source, deviceInfo) { const now = (/* @__PURE__ */ new Date()).toISOString(); return { ...source, volumeUUID: deviceInfo.volumeUUID, volumeLabel: deviceInfo.volumeLabel, volumeSerial: deviceInfo.volumeSerial, lastSeen: now, registeredAt: source.registeredAt || now // Preserve original if exists }; } /** * Updates lastSeen timestamp for a recognized device * * @param source - Backup source to update * @returns Updated backup source with current lastSeen */ updateLastSeen(source) { return { ...source, lastSeen: (/* @__PURE__ */ new Date()).toISOString() }; } /** * Checks if a backup source has device UUID information * * @param source - Backup source to check * @returns true if source has UUID or serial */ hasDeviceInfo(source) { return !!(source.volumeUUID || source.volumeSerial); } } class DeviceResolver { constructor(matcher) { this.matcher = matcher ?? new DeviceMatcher(); } /** * Resolves a mount path to a backup source, registering or recognizing as needed * * This is the main entry point for device UUID tracking. It: * 1. Tries to match by UUID/serial to existing sources * 2. If matched, updates lastSeen timestamp * 3. If not matched and source doesn't have UUID, registers device * 4. Handles conflicts by throwing descriptive errors * * @param mountPath - Path where device is mounted * @param sourceName - Name of the backup source in config * @param config - Full audio-tools configuration * @returns Resolution result with updated source and action taken */ async resolveDevice(mountPath, sourceName, config) { const sources = config.backup?.sources || []; const source = sources.find((s) => s.name === sourceName); if (!source) { throw new Error(`Backup source not found: ${sourceName}`); } const matchResult = await this.matcher.matchDevice(mountPath, sources); if (matchResult.reason === "conflict") { const sourceNames = matchResult.conflictingSources?.map((s) => s.name).join(", "); throw new Error( `Device UUID conflict: Device at ${mountPath} matches multiple sources: ${sourceNames}. Please check your configuration.` ); } if (matchResult.matched && matchResult.source && matchResult.source.name !== sourceName) { throw new Error( `Device mismatch: Device at ${mountPath} is registered as '${matchResult.source.name}', but you're trying to back it up as '${sourceName}'. Use the correct source name or update your configuration.` ); } if (matchResult.matched && matchResult.source?.name === sourceName) { const updatedSource = this.matcher.updateLastSeen(source); return { source: updatedSource, action: "recognized", message: `Recognized device '${source.name}' at ${mountPath} (UUID: ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial})` }; } if (!matchResult.matched && !this.matcher.hasDeviceInfo(source)) { const registeredSource = this.matcher.registerDevice(source, matchResult.deviceInfo); return { source: registeredSource, action: "registered", message: `Registered new device '${source.name}' at ${mountPath} (UUID: ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial})` }; } if (!matchResult.matched && this.matcher.hasDeviceInfo(source)) { throw new Error( `Device mismatch: Source '${sourceName}' expects UUID ${source.volumeUUID || source.volumeSerial}, but device at ${mountPath} has UUID ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial}. This may be a different physical device. Check your configuration.` ); } return { source, action: "no-change", message: `No device UUID changes for '${source.name}'` }; } } class AutoDetectBackup { constructor(deviceDetector, promptService, deviceResolver) { this.deviceDetector = deviceDetector; this.promptService = promptService; this.deviceResolver = deviceResolver; } async detectAndResolve(mountPath, config, options = {}) { let wasPrompted = false; const deviceInfo = await this.detectDeviceInfo(mountPath); const existingSource = this.findExistingSource(deviceInfo, config); if (existingSource) { const updatedSource = { ...existingSource, lastSeen: (/* @__PURE__ */ new Date()).toISOString() }; return { source: updatedSource, action: "recognized", deviceInfo, wasPrompted }; } let deviceType = options.deviceType; if (!deviceType) { deviceType = this.inferDeviceType(deviceInfo); if (!deviceType) { deviceType = await this.promptService.promptDeviceType(); wasPrompted = true; } } let sampler = options.sampler; if (!sampler) { sampler = await this.determineSampler(config); wasPrompted = true; } const sourceName = this.generateSourceName(sampler, deviceType, deviceInfo); const newSource = this.createBackupSource( sourceName, mountPath, deviceType, sampler, deviceInfo ); const action = this.hasDeviceIdentifiers(deviceInfo) ? "registered" : "created"; return { source: newSource, action, deviceInfo, wasPrompted }; } /** * Detect device info with graceful fallback. * Returns partial info if detection fails. */ async detectDeviceInfo(mountPath) { try { return await this.deviceDetector.detectDevice(mountPath); } catch (error) { return { mountPath, volumeLabel: this.extractVolumeNameFromPath(mountPath) }; } } /** * Infer device type from filesystem type. * * Logic: * - FAT12/FAT16 → 'floppy' (typical for floppy disks) * - FAT32 → 'hard-drive' (typical for SD cards, USB drives) * - ISO9660/CDFS → 'cd-rom' * - exFAT, NTFS → 'hard-drive' * - Unknown → undefined (will prompt) */ inferDeviceType(deviceInfo) { const fs = deviceInfo.filesystem?.toLowerCase(); if (!fs) { return void 0; } if (fs.includes("fat12") || fs.includes("fat16")) { return "floppy"; } if (fs.includes("fat32") || fs.includes("vfat")) { return "hard-drive"; } if (fs.includes("iso9660") || fs.includes("cdfs") || fs.includes("udf")) { return "cd-rom"; } if (fs.includes("exfat") || fs.includes("ntfs") || fs.includes("ext4")) { return "hard-drive"; } if (fs.includes("hfs")) { return "hard-drive"; } return void 0; } /** * Determine sampler from config or prompt. * * If no existing samplers, prompts for new sampler name. * If existing samplers, prompts to select or add new. */ async determineSampler(config) { const existingSamplers = this.getExistingSamplers(config); if (existingSamplers.length === 0) { return await this.promptService.promptNewSamplerName(); } const result = await this.promptService.promptSampler(existingSamplers); return result.sampler; } /** * Get list of unique sampler names from config. */ getExistingSamplers(config) { const sources = config.backup?.sources || []; const samplers = sources.map((s) => s.sampler).filter((s) => !!s); return Array.from(new Set(samplers)); } /** * Find existing source that matches the device info. * * Matches by UUID or serial number if available. */ findExistingSource(deviceInfo, config) { const sources = config.backup?.sources || []; if (deviceInfo.volumeUUID) { const match = sources.find((s) => s.volumeUUID === deviceInfo.volumeUUID); if (match) { return match; } } if (deviceInfo.volumeSerial) { const match = sources.find((s) => s.volumeSerial === deviceInfo.volumeSerial); if (match) { return match; } } return void 0; } /** * Generate source name from components. * * Format: ${sampler}-${deviceType}-${volumeLabel || 'device'} * Example: 's5000-floppy-SDCARD' * * Ensures uniqueness by appending number if needed. */ generateSourceName(sampler, deviceType, deviceInfo) { const volumePart = deviceInfo.volumeLabel || "device"; const baseName = `${sampler}-${deviceType}-${volumePart}`; return baseName.toLowerCase().replace(/\s+/g, "-"); } /** * Create a new BackupSource from components. */ createBackupSource(name, mountPath, deviceType, sampler, deviceInfo) { const now = (/* @__PURE__ */ new Date()).toISOString(); return { name, type: "local", source: mountPath, device: deviceType, sampler, enabled: true, volumeUUID: deviceInfo.volumeUUID, volumeLabel: deviceInfo.volumeLabel, volumeSerial: deviceInfo.volumeSerial, registeredAt: now, lastSeen: now }; } /** * Check if device info has UUID or serial identifiers. */ hasDeviceIdentifiers(deviceInfo) { return !!(deviceInfo.volumeUUID || deviceInfo.volumeSerial); } /** * Extract volume name from mount path. * E.g., '/Volumes/SDCARD' → 'SDCARD' */ extractVolumeNameFromPath(mountPath) { const parts = mountPath.split("/").filter((p) => p); return parts[parts.length - 1] || "unknown"; } } function createAutoDetectBackup(deviceDetector, promptService, deviceResolver) { return new AutoDetectBackup(deviceDetector, promptService, deviceResolver); } class UserCancelledError extends Error { constructor(message = "User cancelled the operation") { super(message); this.name = "UserCancelledError"; } } const DEVICE_TYPES = [ { name: "Floppy Disk", value: "floppy", description: 'Standard 3.5" floppy disk' }, { name: "Hard Drive", value: "hard-drive", description: "Internal or external hard drive" }, { name: "CD-ROM", value: "cd-rom", description: "CD-ROM or optical disc" }, { name: "Other", value: "other", description: "Other storage device" } ]; class InteractivePrompt { constructor(options = {}) { this.options = options; } /** * Prompt user to select a device type. */ async promptDeviceType() { try { const answer = await inquirer.prompt([ { type: "list", name: "deviceType", message: "What type of storage device is this?", choices: DEVICE_TYPES.map((dt) => ({ name: `${dt.name} - ${dt.description}`, value: dt.value })), default: "floppy", ...this.getStreamOptions() } ]); return answer.deviceType; } catch (error) { this.handleCancellation(error); throw error; } } /** * Prompt user to select existing sampler or add new. */ async promptSampler(existingSamplers) { try { const ADD_NEW_VALUE = "__ADD_NEW__"; const choices = [ ...existingSamplers.map((name) => ({ name, value: name })), { name: "➕ Add new sampler...", value: ADD_NEW_VALUE } ]; const answer = await inquirer.prompt([ { type: "list", name: "sampler", message: "Select sampler for this backup source:", choices, ...this.getStreamOptions() } ]); if (answer.sampler === ADD_NEW_VALUE) { const newName = await this.promptNewSamplerName(); return { sampler: newName, isNew: true }; } return { sampler: answer.sampler, isNew: false }; } catch (error) { this.handleCancellation(error); throw error; } } /** * Prompt user to enter a new sampler name. */ async promptNewSamplerName() { try { const answer = await inquirer.prompt([ { type: "input", name: "samplerName", message: "Enter a name for the new sampler:", validate: (input) => { const trimmed = input.trim(); if (!trimmed) { return "Sampler name cannot be empty"; } if (trimmed.length > 50) { return "Sampler name must be 50 characters or less"; } if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { return "Sampler name can only contain letters, numbers, hyphens, and underscores"; } return true; }, filter: (input) => input.trim(), ...this.getStreamOptions() } ]); return answer.samplerName; } catch (error) { this.handleCancellation(error); throw error; } } /** * Get input/output stream options for inquirer prompts. * Used for testing with custom streams. */ getStreamOptions() { const opts = {}; if (this.options.input) { opts.input = this.options.input; } if (this.options.output) { opts.output = this.options.output; } return opts; } /** * Handle user cancellation (Ctrl+C) gracefully. * Converts inquirer errors to UserCancelledError. */ handleCancellation(error) { if (error instanceof Error) { if (error.message.includes("User force closed") || error.message.includes("canceled")) { throw new UserCancelledError(); } } } } function createInteractivePrompt(options) { return new InteractivePrompt(options); } export { AutoDetectBackup as A, DeviceResolver as D, InteractivePrompt as I, LocalSource as L, RemoteSource as R, UserCancelledError as U, DeviceMatcher as a, DEVICE_TYPES as b, createAutoDetectBackup as c, createInteractivePrompt as d }; //# sourceMappingURL=interactive-prompt-BEay0YJ3.js.map