/** * Storage Set Path Command * Relocate a local storage destination to a new directory, migrating * any archives that are actually there. * * Motivating case (#566): celilo-mgr's `local-backups` points at * `/Users/pbanka/...`, a macOS path carried across when the database was * restored onto a Linux host. The directory does not exist there, so the * relocation must complete cleanly with nothing to migrate rather than * erroring — and must not carry the old `✓ Verified` stamp onto the new * path. */ import { cpSync, readdirSync, rmSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { getBackupStorageByStorageId, getStorageCredentials, updateStorageCredentials, verifyBackupStorage, } from '../../services/backup-storage'; import { BACKUP_PREFIX } from '../../services/storage-providers/local'; import { celiloIntro, celiloOutro } from '../prompts'; import type { CommandResult } from '../types'; import { probePathWriteable } from './storage-add-local'; /** What the current archive directory turned out to be, on disk. */ export type SourceState = 'missing' | 'unreadable' | 'empty' | 'populated'; export interface RelocationPlan { /** Whether to copy the source tree to the target. */ migrate: boolean; /** One line for the operator explaining what will (not) happen. */ note: string; } /** * Decide whether to migrate. Pure — takes an already-observed source * state so it is testable without a filesystem (Rule 10.4). */ export function planRelocation(input: { sourceState: SourceState; sourceDir: string; targetDir: string; migrateRequested: boolean; }): RelocationPlan { const { sourceState, sourceDir, targetDir, migrateRequested } = input; if (!migrateRequested) { return { migrate: false, note: `Migration skipped (--no-migrate). ${sourceDir} left as-is.` }; } switch (sourceState) { case 'missing': return { migrate: false, note: `${sourceDir} does not exist — nothing to migrate.` }; case 'unreadable': return { migrate: false, note: `${sourceDir} is not readable — nothing migrated.` }; case 'empty': return { migrate: false, note: `${sourceDir} is empty — nothing to migrate.` }; case 'populated': return { migrate: true, note: `Migrating archives from ${sourceDir} to ${targetDir}.` }; } } /** * Validate and normalise the requested path. Pure apart from `~` * expansion, which reads the environment but touches no filesystem. */ export function resolveTargetPath( raw: string, currentPath: string, ): CommandResult | { path: string } { const expanded = raw.startsWith('~/') || raw === '~' ? raw.replace('~', homedir()) : raw; const resolved = resolve(expanded); if (resolved === resolve(currentPath)) { return { success: false, error: `Storage path is already '${resolved}' — nothing to do.` }; } return { path: resolved }; } /** Observe the archive directory. Distinguishes absent from unreadable. */ export function inspectSourceDir(dir: string): SourceState { try { return readdirSync(dir).length === 0 ? 'empty' : 'populated'; } catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unreadable'; } } export async function handleStorageSetPath( args: string[], flags: Record = {}, ): Promise { try { celiloIntro('Relocate Backup Storage'); const storageId = args[0]; const newPathArg = args[1]; if (!storageId || !newPathArg) { return { success: false, error: 'Storage ID and new path are required\n\nUsage: celilo storage set-path [--no-migrate]', }; } const storage = getBackupStorageByStorageId(storageId); if (!storage) { return { success: false, error: `Storage not found: ${storageId}` }; } if (storage.providerName !== 'local') { return { success: false, error: `'${storage.storageId}' is a ${storage.providerName} destination — set-path only applies to local storage.`, }; } const credentials = await getStorageCredentials(storage.id); if (!('path' in credentials)) { return { success: false, error: `Storage '${storage.storageId}' has no path configured.` }; } const currentPath = credentials.path; const resolved = resolveTargetPath(newPathArg, currentPath); if ('success' in resolved) return resolved; const newPath = resolved.path; const writeError = probePathWriteable(newPath); if (writeError !== null) { return { success: false, error: `Path '${newPath}' is not writeable: ${writeError}` }; } const sourceDir = join(currentPath, BACKUP_PREFIX); const targetDir = join(newPath, BACKUP_PREFIX); const plan = planRelocation({ sourceState: inspectSourceDir(sourceDir), sourceDir, targetDir, migrateRequested: flags['no-migrate'] !== true, }); console.log(`\n${plan.note}`); if (plan.migrate) { // A backup archive's mtime is part of what it is; a move must not // restamp it. Bun's cpSync already preserves timestamps, so the // flag is a no-op today — it is here for Node semantics, where the // default is the other way. The mtime test guards the behavior, // not this flag. cpSync(sourceDir, targetDir, { recursive: true, force: true, preserveTimestamps: true }); } await updateStorageCredentials(storage.id, { ...credentials, path: newPath }); console.log(`✓ Path updated: ${currentPath} → ${newPath}`); if (plan.migrate) { // Only after the DB points at the copy, so a failure here leaves // archives duplicated rather than orphaned. try { rmSync(sourceDir, { recursive: true, force: true }); } catch (error) { console.log( `⚠ Copied, but could not remove ${sourceDir}: ${error instanceof Error ? error.message : String(error)}`, ); } } const { result } = await verifyBackupStorage(storage.id); if (!result.success) { console.log(`✗ ${result.message}`); celiloOutro( `Path changed but verification failed.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`, ); return { success: false, error: result.message }; } console.log(`✓ ${result.message}`); celiloOutro(`'${storage.storageId}' now stores backups at ${targetDir}/`); return { success: true, message: `Relocated ${storage.storageId} to ${newPath}` }; } catch (error) { return { success: false, error: `Failed to set storage path: ${error instanceof Error ? error.message : String(error)}`, }; } }