/** * Storage Add Local Command * Configure a local filesystem backup storage destination */ import { mkdirSync, rmSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { getDataDir } from '../../config/paths'; import { addBackupStorage, setDefaultBackupStorage, verifyBackupStorage, } from '../../services/backup-storage'; import { askConfirm, askText, withInterviewSession } from '../../services/bus-interview'; import { celiloIntro, celiloOutro } from '../prompts'; import type { CommandResult } from '../types'; /** * Best-effort write probe. Attempts to mkdir -p a `.celilo-write-probe` * subdir, then deletes it. Returns null on success, or a one-line * error message on failure (suitable for surfacing to the operator). * * Exists so we can catch unwriteable paths AT INTERVIEW TIME instead * of saving a storage row, failing the heavyweight verify step, and * leaving a dangling unverified row that confuses subsequent * `system update` runs (the regression that prompted this work). */ export function probePathWriteable(path: string): string | null { const probe = join(path, '.celilo-write-probe'); try { mkdirSync(probe, { recursive: true }); rmSync(probe, { recursive: true, force: true }); return null; } catch (err) { return err instanceof Error ? err.message : String(err); } } /** * Expand leading tilde to the user's home directory. * Node.js fs functions don't expand ~ like the shell does. */ function expandTilde(path: string): string { if (path.startsWith('~/') || path === '~') { return path.replace('~', homedir()); } return path; } export async function handleStorageAddLocal( _args: string[], flags: Record = {}, ): Promise { // Non-secret prompts route through the bus interview (ISS-0127). Local // storage has no secret credentials. `withInterviewSession` renders bus // questions locally when stdin is a TTY. const scope = 'storage-add:local'; return withInterviewSession(async () => { try { celiloIntro('Add Local Backup Storage'); // Support non-interactive mode via flags const flagName = typeof flags.name === 'string' ? flags.name : undefined; const flagPath = typeof flags.path === 'string' ? flags.path : undefined; const name = flagName ?? (await askText({ scope, key: 'name', message: 'Human-readable name', defaultValue: 'Local Backups', placeholder: 'Local Backups', required: true, })); // Default path lives under celilo's own data dir // (~/.local/share/celilo/backups on Linux, ~/Library/Application // Support/celilo/backups on macOS). The directory is owned by the // running user, mkdir -p succeeds without sudo, and operators who // want NAS / external paths can override at the prompt. This // replaces the prior placeholder of "/var/backups/celilo" which // looked authoritative but required root to write. const defaultPath = join(getDataDir(), 'backups'); // Probe writability BEFORE saving the storage row. The previous // flow (save → verify → fail with EACCES → leave dangling // unverified row) was confusing and required the operator to know // about `storage verify` to recover. Probing here means an // unwriteable path never produces persistent state. // // Non-interactive (--name + --path): probe once, fail loud. // Interactive: re-prompt until a writeable path is supplied. let resolvedPath: string; if (flagPath !== undefined) { resolvedPath = resolve(expandTilde(flagPath)); const writeError = probePathWriteable(resolvedPath); if (writeError !== null) { return { success: false, error: `Path '${resolvedPath}' is not writeable: ${writeError}\n\nTry a path under your home directory (e.g. '${defaultPath}') or run with elevated permissions.`, }; } } else { let candidate: string | undefined; while (candidate === undefined) { const typed = await askText({ scope, key: 'path', message: 'Storage directory path', defaultValue: defaultPath, placeholder: defaultPath, required: true, }); const resolved = resolve(expandTilde(typed)); const writeError = probePathWriteable(resolved); if (writeError === null) { candidate = resolved; break; } console.log( `\n✗ Path '${resolved}' is not writeable: ${writeError}\nTry a path under your home directory (default '${defaultPath}' works without sudo).\n`, ); // Loop continues — re-prompt with the same default suggestion. } resolvedPath = candidate; } const storage = await addBackupStorage({ name, providerName: 'local', credentials: { path: resolvedPath }, }); console.log(`\nStorage '${storage.storageId}' saved`); console.log('Testing storage access...'); const { result } = await verifyBackupStorage(storage.id); if (!result.success) { // Should be unreachable in interactive mode (the pre-save probe // covers the EACCES path); could still fire for less-common // verify failures (full disk, etc.). Keep the dangling-row // recovery hint as a safety net. console.log(`\n✗ Verification failed: ${result.message}`); celiloOutro( `Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`, ); return { success: true, message: `Added storage: ${storage.storageId} (not verified)` }; } console.log(`✓ ${result.message}`); if (flagName && flagPath) { // Non-interactive: auto-set as default setDefaultBackupStorage(storage.id); console.log('✓ Set as default'); } else { const makeDefault = await askConfirm({ scope, key: 'make_default', message: 'Set as default backup destination?', defaultValue: true, }); if (makeDefault) { setDefaultBackupStorage(storage.id); console.log('✓ Set as default'); } } celiloOutro( `Storage '${storage.storageId}' (${name}) added and verified!\n\nStorage ID: ${storage.storageId}\nPath: ${resolvedPath}\n\nNext steps:\n celilo module backup Create a backup\n celilo storage list List storage destinations`, ); return { success: true, message: `Added local storage: ${storage.storageId}` }; } catch (error) { return { success: false, error: `Failed to add local storage: ${error instanceof Error ? error.message : String(error)}`, }; } }); }