/** * Backup Pull Command * * Downloads a backup artifact that ANOTHER box wrote to a shared storage * destination, onto THIS box, as a local `.backup` file ready for * `celilo restore --from `. * * This closes the fresh-box gap in the S3 migration path: `restore --from` * needs a local file, `backup restore ` needs a local DB row, and * `backup import` goes the wrong way. None of them let a freshly-bootstrapped * celilo-mgr fetch turnip's backup straight out of the bucket. The storage * provider already exposes list()/download(); this is the missing glue. * * celilo storage add s3 ... # same creds as the source box * celilo backup pull --storage --module celilo-mgmt * celilo restore --from --force * * Part of P5 (openspec/specs/management-server-backup/spec.md), apps/celilo/designs/P5_MIGRATION_E2E.md. */ import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { createStorageProvider, getBackupStorageByStorageId, getDefaultBackupStorage, } from '../../services/backup-storage'; import { celiloIntro, celiloOutro } from '../prompts'; import type { CommandResult } from '../types'; /** * Pick the newest backup artifact key from a storage listing. * * Storage layout (backup-create.ts): `/-.backup` * where `` is the moduleId for module backups or `system` for * system-state backups, and `` is an ISO timestamp with `:`/`.` * replaced by `-`. ISO timestamps sort lexically as chronologically, so the * lexically-greatest matching key is the newest. * * `keyPrefix` matches the basename's leading `-<4-digit-year>` so a * module id is never a false-prefix of a longer one (e.g. `celilo` vs * `celilo-mgmt`). */ export function pickNewestArtifact(keys: string[], keyPrefix: string): string | null { const matcher = new RegExp(`^${keyPrefix}-\\d{4}-.*\\.backup$`); const matching = keys.filter((k) => matcher.test(basename(k))); if (matching.length === 0) return null; // Lexical sort; newest (greatest) last. matching.sort(); return matching[matching.length - 1] ?? null; } export async function handleBackupPull( _args: string[], flags: Record = {}, ): Promise { try { celiloIntro('Pull Backup'); const storageName = typeof flags.storage === 'string' ? flags.storage : undefined; const moduleId = typeof flags.module === 'string' ? flags.module : undefined; const outputFlag = typeof flags.output === 'string' ? flags.output : undefined; const storage = storageName ? getBackupStorageByStorageId(storageName) : getDefaultBackupStorage(); if (!storage) { return { success: false, error: storageName ? `Storage not found: ${storageName}\n\nList destinations: celilo storage list` : 'No default backup storage configured.\n\nAdd storage first: celilo storage add s3', }; } if (!storage.verified) { return { success: false, error: `Storage '${storage.storageId}' is not verified. Run: celilo storage verify ${storage.storageId}`, }; } // `system` is the artifact prefix for system-state backups; otherwise we // match the module's artifacts. const keyPrefix = moduleId ?? 'system'; console.log(`\n▸ Listing backups in '${storage.storageId}'...`); const provider = await createStorageProvider(storage.id); const keys = await provider.list(''); const remotePath = pickNewestArtifact(keys, keyPrefix); if (!remotePath) { return { success: false, error: `No '${keyPrefix}' backup artifact found in storage '${storage.storageId}'.\n\nExpected an object like /${keyPrefix}-.backup`, }; } const outputPath = outputFlag ?? join(tmpdir(), basename(remotePath)); console.log(`▸ Downloading ${remotePath}...`); await provider.download(remotePath, outputPath); console.log(`✓ Pulled ${basename(remotePath)}`); console.log(` → ${outputPath}`); celiloOutro(`Restore it with:\n celilo restore --from ${outputPath} --force`); return { success: true, message: `Pulled backup to ${outputPath}` }; } catch (error) { return { success: false, error: `Pull failed: ${error instanceof Error ? error.message : String(error)}`, }; } }