diff --git a/README.md b/README.md index 9b72dbc6..2c2422d5 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ rli snapshot list # List all snapshots rli snapshot create # Create a snapshot of a devbox rli snapshot delete # Delete a snapshot rli snapshot get # Get snapshot details +rli snapshot prune # Delete old snapshots for a devbox, ke... rli snapshot status # Get snapshot operation status ``` diff --git a/package.json b/package.json index 701803b8..f432a4e1 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "access": "public" }, "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@modelcontextprotocol/sdk": "^1.26.0", "@runloop/api-client": "1.6.0", "@types/express": "^5.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57277f60..0201856a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(zod@4.3.6) @@ -672,6 +675,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -2161,6 +2168,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3788,6 +3798,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) @@ -5738,6 +5752,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts index 10107999..e8898c39 100644 --- a/src/commands/blueprint/prune.ts +++ b/src/commands/blueprint/prune.ts @@ -5,6 +5,7 @@ import * as readline from "readline"; import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { formatRelativeTime } from "../../utils/time.js"; import type { Blueprint } from "../../store/blueprintStore.js"; interface PruneBlueprintsOptions { @@ -87,29 +88,6 @@ function categorizeBlueprints(blueprints: Blueprint[], keepCount: number) { }; } -/** - * Format a timestamp for display - */ -function formatTimestamp(createTimeMs?: number): string { - if (!createTimeMs) { - return "unknown time"; - } - - const now = Date.now(); - const diffMs = now - createTimeMs; - const diffMinutes = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMinutes < 60) { - return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`; - } else if (diffHours < 24) { - return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; - } else { - return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`; - } -} - /** * Display a summary of what will be kept and deleted */ @@ -145,7 +123,7 @@ function displaySummary( } else { for (const blueprint of result.toKeep) { console.log( - ` ✓ ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)}`, + ` ✓ ${blueprint.id} - Created ${formatRelativeTime(blueprint.create_time_ms)}`, ); } } @@ -163,7 +141,7 @@ function displaySummary( const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed"; console.log( - ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`, + ` ${icon} ${blueprint.id} - Created ${formatRelativeTime(blueprint.create_time_ms)} (${statusLabel})`, ); } } @@ -183,7 +161,7 @@ function displayDeletedBlueprints(deleted: Blueprint[]) { const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed"; console.log( - ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`, + ` ${icon} ${blueprint.id} - Created ${formatRelativeTime(blueprint.create_time_ms)} (${statusLabel})`, ); } } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 1a1762f7..ff523a1f 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Text, useInput, useApp } from "ink"; import figures from "figures"; import type { DiskSnapshotsCursorIDPage } from "@runloop/api-client/pagination"; +import type { DevboxSnapshotView } from "@runloop/api-client/resources/devboxes/devboxes"; import { getClient } from "../../utils/client.js"; import { Header } from "../../components/Header.js"; import { SpinnerComponent } from "../../components/Spinner.js"; @@ -696,10 +697,19 @@ export async function listSnapshots(options: ListOptions) { // Fetch snapshots const page = (await client.devboxes.listDiskSnapshots( queryParams, - )) as DiskSnapshotsCursorIDPage<{ id: string }>; - - // Extract snapshots array - const snapshots = page.snapshots || []; + )) as DiskSnapshotsCursorIDPage; + + // Extract snapshots array and strip to plain objects to avoid + // camelCase aliases added by the API client library + const snapshots = (page.snapshots || []).map((s) => ({ + id: s.id, + name: s.name ?? undefined, + create_time_ms: s.create_time_ms, + metadata: s.metadata, + source_devbox_id: s.source_devbox_id, + source_blueprint_id: s.source_blueprint_id ?? undefined, + commit_message: s.commit_message ?? undefined, + })); output(snapshots, { format: options.output, defaultFormat: "json" }); } catch (error) { diff --git a/src/commands/snapshot/prune.ts b/src/commands/snapshot/prune.ts new file mode 100644 index 00000000..4dbc0722 --- /dev/null +++ b/src/commands/snapshot/prune.ts @@ -0,0 +1,368 @@ +/** + * Snapshot prune command - Delete old snapshots for a given source devbox + */ + +import * as readline from "readline"; +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; +import { formatRelativeTime } from "../../utils/time.js"; + +interface SnapshotItem { + id: string; + name?: string; + status?: string; + create_time_ms?: number; + source_devbox_id?: string; +} + +interface PruneSnapshotsOptions { + dryRun?: boolean; + yes?: boolean; + keep?: string; + output?: string; +} + +interface PruneResult { + sourceDevboxId: string; + totalFound: number; + successfulSnapshots: number; + failedSnapshots: number; + kept: SnapshotItem[]; + deleted: SnapshotItem[]; + failed: Array<{ id: string; error: string }>; + dryRun: boolean; +} + +/** + * Query the async status for a snapshot and return a normalized status string. + * Maps API statuses: "complete" → "ready", others passed through. + */ +async function querySnapshotStatus(snapshotId: string): Promise { + const client = getClient(); + try { + const statusResponse = + await client.devboxes.diskSnapshots.queryStatus(snapshotId); + const operationStatus = statusResponse.status; + return operationStatus === "complete" ? "ready" : operationStatus; + } catch { + return "unknown"; + } +} + +/** + * Fetch all snapshots for a given source devbox (handles pagination) + * and enrich each snapshot with its async operation status. + */ +async function fetchAllSnapshotsForDevbox( + devboxId: string, +): Promise { + const client = getClient(); + const allSnapshots: SnapshotItem[] = []; + let hasMore = true; + let startingAfter: string | undefined = undefined; + + while (hasMore) { + const params: Record = { + devbox_id: devboxId, + limit: 100, + }; + if (startingAfter) { + params.starting_after = startingAfter; + } + + try { + const page = await client.devboxes.listDiskSnapshots(params); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const snapshots = ((page as any).snapshots || []) as SnapshotItem[]; + allSnapshots.push(...snapshots); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hasMore = (page as any).has_more || false; + if (hasMore && snapshots.length > 0) { + startingAfter = snapshots[snapshots.length - 1].id; + } else { + hasMore = false; + } + } catch (error) { + console.error("Warning: Error fetching snapshots:", error); + // Continue with partial results + hasMore = false; + } + } + + // The listDiskSnapshots endpoint does not include status — query it for each snapshot + const enriched = await Promise.all( + allSnapshots.map(async (snapshot) => ({ + ...snapshot, + status: await querySnapshotStatus(snapshot.id), + })), + ); + + return enriched; +} + +/** + * Categorize snapshots into successful and failed, and determine what to keep/delete + */ +function categorizeSnapshots(snapshots: SnapshotItem[], keepCount: number) { + // Filter successful snapshots (status "ready" means completed successfully) + const successful = snapshots.filter((s) => s.status === "ready"); + + // Filter failed/incomplete snapshots + const failed = snapshots.filter((s) => s.status !== "ready"); + + // Sort successful by create_time_ms descending (newest first) + successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0)); + + // Determine what to keep and delete + const toKeep = successful.slice(0, keepCount); + const toDelete = [...successful.slice(keepCount), ...failed]; + + return { + toKeep, + toDelete, + successful, + failed, + }; +} + +/** + * Display a summary of what will be kept and deleted + */ +function displaySummary( + devboxId: string, + result: { + toKeep: SnapshotItem[]; + toDelete: SnapshotItem[]; + successful: SnapshotItem[]; + failed: SnapshotItem[]; + }, + isDryRun: boolean, +) { + const total = result.successful.length + result.failed.length; + + console.log(`\nAnalyzing snapshots for devbox "${devboxId}"...`); + console.log(`\nFound ${total} snapshot${total !== 1 ? "s" : ""}:`); + console.log( + ` ✓ ${result.successful.length} ready snapshot${result.successful.length !== 1 ? "s" : ""}`, + ); + console.log( + ` ✗ ${result.failed.length} failed/incomplete snapshot${result.failed.length !== 1 ? "s" : ""}`, + ); + + // Show what will be kept + console.log(`\nKeeping (${result.toKeep.length} most recent ready):`); + if (result.toKeep.length === 0) { + console.log(" (none - no ready snapshots found)"); + } else { + for (const snapshot of result.toKeep) { + const label = snapshot.name ? ` "${snapshot.name}"` : ""; + console.log( + ` ✓ ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)}`, + ); + } + } + + // Show what will be deleted + console.log( + `\n${isDryRun ? "Would delete" : "To be deleted"} (${result.toDelete.length} snapshot${result.toDelete.length !== 1 ? "s" : ""}):`, + ); + if (result.toDelete.length === 0) { + console.log(" (none)"); + } else { + for (const snapshot of result.toDelete) { + const icon = snapshot.status === "ready" ? "✓" : "⚠"; + const statusLabel = + snapshot.status === "ready" ? "ready" : snapshot.status || "unknown"; + const label = snapshot.name ? ` "${snapshot.name}"` : ""; + console.log( + ` ${icon} ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)} (${statusLabel})`, + ); + } + } +} + +/** + * Display all deleted snapshots + */ +function displayDeletedSnapshots(deleted: SnapshotItem[]) { + if (deleted.length === 0) { + return; + } + + console.log("\nDeleted snapshots:"); + for (const snapshot of deleted) { + const icon = snapshot.status === "ready" ? "✓" : "⚠"; + const statusLabel = + snapshot.status === "ready" ? "ready" : snapshot.status || "unknown"; + const label = snapshot.name ? ` "${snapshot.name}"` : ""; + console.log( + ` ${icon} ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)} (${statusLabel})`, + ); + } +} + +/** + * Prompt user for confirmation + */ +async function confirmDeletion(count: number): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question( + `\nDelete ${count} snapshot${count !== 1 ? "s" : ""}? (y/N): `, + (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }, + ); + }); +} + +/** + * Delete snapshots with error tracking + */ +async function deleteSnapshotsWithTracking(snapshots: SnapshotItem[]) { + const client = getClient(); + const results = { + deleted: [] as SnapshotItem[], + failed: [] as Array<{ id: string; error: string }>, + }; + + for (const snapshot of snapshots) { + try { + await client.devboxes.diskSnapshots.delete(snapshot.id); + results.deleted.push(snapshot); + } catch (error) { + results.failed.push({ + id: snapshot.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +/** + * Main prune function + */ +export async function pruneSnapshots( + devboxId: string, + options: PruneSnapshotsOptions = {}, +) { + try { + // Parse and validate options + const isDryRun = !!options.dryRun; + const autoConfirm = !!options.yes; + const keepCount = parseInt(options.keep || "1", 10); + + if (isNaN(keepCount) || keepCount < 0) { + outputError("--keep must be a non-negative integer"); + } + + // Fetch all snapshots for the given devbox + console.log(`Fetching snapshots for devbox "${devboxId}"...`); + const snapshots = await fetchAllSnapshotsForDevbox(devboxId); + + // Handle no snapshots found + if (snapshots.length === 0) { + console.log(`No snapshots found for devbox: ${devboxId}`); + return; + } + + // Categorize snapshots + const categorized = categorizeSnapshots(snapshots, keepCount); + + // Display summary + displaySummary(devboxId, categorized, isDryRun); + + // Handle dry-run mode + if (isDryRun) { + console.log("\n(Dry run - no changes made)"); + const result: PruneResult = { + sourceDevboxId: devboxId, + totalFound: snapshots.length, + successfulSnapshots: categorized.successful.length, + failedSnapshots: categorized.failed.length, + kept: categorized.toKeep, + deleted: [], + failed: [], + dryRun: true, + }; + + if (options.output && options.output !== "text") { + output(result, { format: options.output, defaultFormat: "json" }); + } + return; + } + + // Handle nothing to delete + if (categorized.toDelete.length === 0) { + console.log("\nNothing to delete."); + return; + } + + // Warn if no successful snapshots + if (categorized.successful.length === 0) { + console.log( + "\nWarning: No ready snapshots found. Only deleting failed/incomplete snapshots.", + ); + } + + // Get confirmation unless --yes flag is set + if (!autoConfirm) { + const confirmed = await confirmDeletion(categorized.toDelete.length); + if (!confirmed) { + console.log("\nOperation cancelled."); + return; + } + } + + // Perform deletions + console.log( + `\nDeleting ${categorized.toDelete.length} snapshot${categorized.toDelete.length !== 1 ? "s" : ""}...`, + ); + const deletionResults = await deleteSnapshotsWithTracking( + categorized.toDelete, + ); + + // Display results + console.log("\nResults:"); + console.log( + ` ✓ Successfully deleted: ${deletionResults.deleted.length} snapshot${deletionResults.deleted.length !== 1 ? "s" : ""}`, + ); + + // Show all deleted snapshots + displayDeletedSnapshots(deletionResults.deleted); + + if (deletionResults.failed.length > 0) { + console.log( + `\n ✗ Failed to delete: ${deletionResults.failed.length} snapshot${deletionResults.failed.length !== 1 ? "s" : ""}`, + ); + for (const failure of deletionResults.failed) { + console.log(` - ${failure.id}: ${failure.error}`); + } + } + + // Output structured data if requested + if (options.output && options.output !== "text") { + const result: PruneResult = { + sourceDevboxId: devboxId, + totalFound: snapshots.length, + successfulSnapshots: categorized.successful.length, + failedSnapshots: categorized.failed.length, + kept: categorized.toKeep, + deleted: deletionResults.deleted, + failed: deletionResults.failed, + dryRun: false, + }; + output(result, { format: options.output, defaultFormat: "json" }); + } + } catch (error) { + outputError("Failed to prune snapshots", error); + } +} diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 45b3c472..4465f94a 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -15,6 +15,7 @@ import { import { getDevboxUrl } from "../utils/url.js"; import { colors } from "../utils/theme.js"; import { getDevbox } from "../services/devboxService.js"; +import { formatTimeAgo } from "../utils/time.js"; import type { Devbox } from "../store/devboxStore.js"; interface DevboxDetailPageProps { @@ -22,28 +23,6 @@ interface DevboxDetailPageProps { onBack: () => void; } -// Format time ago in a succinct way -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx index 772bdf20..81eb3f66 100644 --- a/src/components/ResourceDetailPage.tsx +++ b/src/components/ResourceDetailPage.tsx @@ -12,6 +12,7 @@ import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { formatTimeAgo } from "../utils/time.js"; // Types for configurable detail sections export interface DetailField { @@ -68,28 +69,6 @@ export interface ResourceDetailPageProps { pollInterval?: number; } -// Format time ago in a succinct way -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - // Truncate long strings to prevent layout issues const truncateString = (str: string, maxLength: number): string => { if (str.length <= maxLength) return str; diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx index af79994f..059ad76d 100644 --- a/src/components/ResourceListView.tsx +++ b/src/components/ResourceListView.tsx @@ -10,46 +10,10 @@ import { Table, Column } from "./Table.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { formatTimeAgoRich } from "../utils/time.js"; -// Format time ago - concise with ISO-style date for older items -export const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - const date = new Date(timestamp); - const now = new Date(); - - // Format time as HH:MM:SS (24h) - const time = date.toTimeString().slice(0, 8); - - // Less than 1 minute - if (seconds < 60) return `${time} (${seconds}s ago)`; - - const minutes = Math.floor(seconds / 60); - // Less than 1 hour - if (minutes < 60) return `${time} (${minutes}m ago)`; - - const hours = Math.floor(minutes / 60); - // Less than 24 hours - show time + relative - if (hours < 24) return `${time} (${hours}hr ago)`; - - const days = Math.floor(hours / 24); - const sameYear = date.getFullYear() === now.getFullYear(); - - // Format date parts - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const year = date.getFullYear(); - - // Date format: MM-DD or YYYY-MM-DD if different year - const dateStr = `${month}-${day}`; - - // 1-7 days - show date + time + relative - if (days <= 7) { - return `${dateStr} ${time} (${days}d)`; - } - - // More than 7 days - just date + time, no relative - return `${dateStr} ${time}`; -}; +// Re-export for backwards compatibility with existing imports +export const formatTimeAgo = formatTimeAgoRich; export interface ResourceListConfig { /** Resource name (e.g., 'Devboxes', 'Blueprints', 'Snapshots') */ diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 66c507f9..4429baa5 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -3,6 +3,7 @@ import { Box, Text } from "ink"; import figures from "figures"; import { colors } from "../utils/theme.js"; import { getStatusDisplay } from "./StatusBadge.js"; +import { formatTimeAgo } from "../utils/time.js"; import type { DevboxView } from "@runloop/api-client/resources/devboxes/devboxes"; type DevboxStatus = DevboxView["status"]; @@ -44,28 +45,6 @@ const formatShutdownReason = (reason: string): string => { } }; -// Format time ago in a succinct way -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - // Format duration in a succinct way const formatDuration = (milliseconds: number): string => { const seconds = Math.floor(milliseconds / 1000); diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 60ed5f29..3c9d043b 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -386,6 +386,23 @@ export function createProgram(): Command { await getSnapshot({ id, ...options }); }); + snapshot + .command("prune ") + .description( + "Delete old snapshots for a devbox, keeping only recent ready ones", + ) + .option("--dry-run", "Show what would be deleted without actually deleting") + .option("-y, --yes", "Skip confirmation prompt") + .option("--keep ", "Number of ready snapshots to keep", "1") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (devboxId, options) => { + const { pruneSnapshots } = await import("../commands/snapshot/prune.js"); + await pruneSnapshots(devboxId, options); + }); + snapshot .command("status ") .description("Get snapshot operation status") diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 00000000..f21511fd --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,104 @@ +/** + * Shared time formatting utilities using the Temporal API. + */ + +import { Temporal } from "@js-temporal/polyfill"; + +/** + * Get elapsed seconds since the given epoch millisecond timestamp. + */ +function getElapsedSeconds(timestampMs: number): number { + const now = Temporal.Now.instant(); + const then = Temporal.Instant.fromEpochMilliseconds(timestampMs); + return Math.floor(now.since(then).total("second")); +} + +/** + * Format a relative timestamp in concise form. + * Examples: "5s ago", "3m ago", "2h ago", "14d ago", "3mo ago", "1y ago" + * + * Used for UI detail components. + */ +export function formatTimeAgo(timestampMs: number): string { + const seconds = getElapsedSeconds(timestampMs); + + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + + const years = Math.floor(months / 12); + return `${years}y ago`; +} + +/** + * Format a relative timestamp in verbose form. + * Examples: "5 minutes ago", "3 hours ago", "14 days ago" + * + * Used for CLI text output (e.g. prune commands). + */ +export function formatRelativeTime(timestampMs: number | undefined): string { + if (!timestampMs) return "unknown time"; + + const seconds = getElapsedSeconds(timestampMs); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(seconds / 3600); + const days = Math.floor(seconds / 86400); + + if (minutes < 60) { + return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } else if (hours < 24) { + return `${hours} hour${hours !== 1 ? "s" : ""} ago`; + } else { + return `${days} day${days !== 1 ? "s" : ""} ago`; + } +} + +/** + * Format a timestamp with HH:MM:SS time and a relative indicator. + * Examples: "14:30:05 (3m ago)", "01-15 09:00:00 (2d)" + * + * Used for resource list views. + */ +export function formatTimeAgoRich(timestampMs: number): string { + const instant = Temporal.Instant.fromEpochMilliseconds(timestampMs); + const zdt = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()); + + const time = `${String(zdt.hour).padStart(2, "0")}:${String(zdt.minute).padStart(2, "0")}:${String(zdt.second).padStart(2, "0")}`; + + const seconds = getElapsedSeconds(timestampMs); + + // Less than 1 minute + if (seconds < 60) return `${time} (${seconds}s ago)`; + + const minutes = Math.floor(seconds / 60); + // Less than 1 hour + if (minutes < 60) return `${time} (${minutes}m ago)`; + + const hours = Math.floor(minutes / 60); + // Less than 24 hours + if (hours < 24) return `${time} (${hours}hr ago)`; + + const days = Math.floor(hours / 24); + + const month = String(zdt.month).padStart(2, "0"); + const day = String(zdt.day).padStart(2, "0"); + const dateStr = `${month}-${day}`; + + // 1-7 days - show date + time + relative + if (days <= 7) { + return `${dateStr} ${time} (${days}d)`; + } + + // More than 7 days - just date + time + return `${dateStr} ${time}`; +}