From dbdeddc40713d9bf850ad234b1af00e55b82e0de Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Fri, 16 Jan 2026 16:43:19 -0800 Subject: [PATCH 1/3] cp dines --- package-lock.json | 12 +- src/commands/blueprint/prune.ts | 365 ++++++++++++++++++++++++++++++++ src/utils/commands.ts | 17 ++ 3 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 src/commands/blueprint/prune.ts diff --git a/package-lock.json b/package-lock.json index e68c8492..cd75a3ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10744,9 +10744,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -10916,9 +10916,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts new file mode 100644 index 00000000..e479615a --- /dev/null +++ b/src/commands/blueprint/prune.ts @@ -0,0 +1,365 @@ +/** + * Blueprint prune command - Delete old blueprint builds + */ + +import * as readline from "readline"; +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; +import type { Blueprint } from "../../store/blueprintStore.js"; + +interface PruneBlueprintsOptions { + dryRun?: boolean; + yes?: boolean; + keep?: string; + output?: string; +} + +interface PruneResult { + blueprintName: string; + totalFound: number; + successfulBuilds: number; + failedBuilds: number; + kept: Blueprint[]; + deleted: Blueprint[]; + failed: Array<{ id: string; error: string }>; + dryRun: boolean; +} + +/** + * Fetch all blueprints with a given name (handles pagination) + */ +async function fetchAllBlueprintsWithName( + name: string, +): Promise { + const client = getClient(); + const allBlueprints: Blueprint[] = []; + let hasMore = true; + let startingAfter: string | undefined = undefined; + + while (hasMore) { + const params: Record = { name, limit: 100 }; + if (startingAfter) { + params.starting_after = startingAfter; + } + + try { + const page = await client.blueprints.list(params); + const blueprints = (page.blueprints || []) as Blueprint[]; + allBlueprints.push(...blueprints); + + hasMore = page.has_more || false; + if (hasMore && blueprints.length > 0) { + startingAfter = blueprints[blueprints.length - 1].id; + } else { + hasMore = false; + } + } catch (error) { + console.error("Warning: Error fetching blueprints:", error); + // Continue with partial results + hasMore = false; + } + } + + return allBlueprints; +} + +/** + * Categorize blueprints into successful and failed, and determine what to keep/delete + */ +function categorizeBlueprints(blueprints: Blueprint[], keepCount: number) { + // Filter successful builds + const successful = blueprints.filter( + (b) => b.status === "build_complete" || b.status === "building_complete", + ); + + // Filter failed builds + const failed = blueprints.filter( + (b) => b.status !== "build_complete" && b.status !== "building_complete", + ); + + // 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, + }; +} + +/** + * 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 + */ +function displaySummary( + name: string, + result: { + toKeep: Blueprint[]; + toDelete: Blueprint[]; + successful: Blueprint[]; + failed: Blueprint[]; + }, + isDryRun: boolean, +) { + const total = result.successful.length + result.failed.length; + + console.log(`\nAnalyzing blueprints named "${name}"...`); + console.log(`\nFound ${total} blueprint${total !== 1 ? "s" : ""}:`); + console.log(` ✓ ${result.successful.length} successful build${result.successful.length !== 1 ? "s" : ""}`); + console.log(` ✗ ${result.failed.length} failed build${result.failed.length !== 1 ? "s" : ""}`); + + // Show what will be kept + console.log(`\nKeeping (${result.toKeep.length} most recent successful):`); + if (result.toKeep.length === 0) { + console.log(" (none - no successful builds found)"); + } else { + for (const blueprint of result.toKeep) { + console.log( + ` ✓ ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)}`, + ); + } + } + + // Show what will be deleted + console.log( + `\n${isDryRun ? "Would delete" : "To be deleted"} (${result.toDelete.length} blueprint${result.toDelete.length !== 1 ? "s" : ""}):`, + ); + if (result.toDelete.length === 0) { + console.log(" (none)"); + } else { + // Show all blueprints without summarizing + for (const blueprint of result.toDelete) { + const icon = + blueprint.status === "build_complete" || + blueprint.status === "building_complete" + ? "✓" + : "✗"; + const statusLabel = + blueprint.status === "build_complete" || + blueprint.status === "building_complete" + ? "successful" + : "failed"; + console.log( + ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`, + ); + } + } +} + +/** + * Display all deleted blueprints + */ +function displayDeletedBlueprints(deleted: Blueprint[]) { + if (deleted.length === 0) { + return; + } + + console.log("\nDeleted blueprints:"); + for (const blueprint of deleted) { + const icon = + blueprint.status === "build_complete" || + blueprint.status === "building_complete" + ? "✓" + : "✗"; + const statusLabel = + blueprint.status === "build_complete" || + blueprint.status === "building_complete" + ? "successful" + : "failed"; + console.log( + ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.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} blueprint${count !== 1 ? "s" : ""}? (y/N): `, + (answer) => { + rl.close(); + resolve( + answer.toLowerCase() === "y" || answer.toLowerCase() === "yes", + ); + }, + ); + }); +} + +/** + * Delete blueprints with error tracking + */ +async function deleteBlueprintsWithTracking(blueprints: Blueprint[]) { + const client = getClient(); + const results = { + deleted: [] as Blueprint[], + failed: [] as Array<{ id: string; error: string }>, + }; + + for (const blueprint of blueprints) { + try { + await client.blueprints.delete(blueprint.id); + results.deleted.push(blueprint); + } catch (error) { + results.failed.push({ + id: blueprint.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +/** + * Main prune function + */ +export async function pruneBlueprints( + name: string, + options: PruneBlueprintsOptions = {}, +) { + try { + // Parse and validate options + const isDryRun = options.dryRun || false; + const autoConfirm = options.yes || false; + const keepCount = parseInt(options.keep || "1", 10); + + if (isNaN(keepCount) || keepCount < 1) { + outputError("--keep must be a positive integer"); + } + + // Fetch all blueprints with the given name + console.log(`Fetching blueprints named "${name}"...`); + const blueprints = await fetchAllBlueprintsWithName(name); + + // Handle no blueprints found + if (blueprints.length === 0) { + console.log(`No blueprints found with name: ${name}`); + return; + } + + // Categorize blueprints + const categorized = categorizeBlueprints(blueprints, keepCount); + + // Display summary + displaySummary(name, categorized, isDryRun); + + // Handle dry-run mode + if (isDryRun) { + console.log("\n(Dry run - no changes made)"); + const result: PruneResult = { + blueprintName: name, + totalFound: blueprints.length, + successfulBuilds: categorized.successful.length, + failedBuilds: 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 builds + if (categorized.successful.length === 0) { + console.log( + "\nWarning: No successful builds found. Only deleting failed builds.", + ); + } + + // 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} blueprint${categorized.toDelete.length !== 1 ? "s" : ""}...`); + const deletionResults = await deleteBlueprintsWithTracking( + categorized.toDelete, + ); + + // Display results + console.log("\nResults:"); + console.log( + ` ✓ Successfully deleted: ${deletionResults.deleted.length} blueprint${deletionResults.deleted.length !== 1 ? "s" : ""}`, + ); + + // Show all deleted blueprints + displayDeletedBlueprints(deletionResults.deleted); + + if (deletionResults.failed.length > 0) { + console.log( + `\n ✗ Failed to delete: ${deletionResults.failed.length} blueprint${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 = { + blueprintName: name, + totalFound: blueprints.length, + successfulBuilds: categorized.successful.length, + failedBuilds: 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 blueprints", error); + } +} diff --git a/src/utils/commands.ts b/src/utils/commands.ts index d9246a88..a38c210a 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -457,6 +457,23 @@ export function createProgram(): Command { await getBlueprintLogs({ id, ...options }); }); + blueprint + .command("prune ") + .description("Delete old blueprint builds, keeping only recent successful ones") + .option("--dry-run", "Show what would be deleted without actually deleting") + .option("-y, --yes", "Skip confirmation prompt") + .option("--keep ", "Number of successful builds to keep", "1") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (name, options) => { + const { pruneBlueprints } = await import( + "../commands/blueprint/prune.js" + ); + await pruneBlueprints(name, options); + }); + // Object storage commands const object = program .command("object") From fecfc01e472a9d7cfce85fce83c31233724b7b16 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Fri, 16 Jan 2026 16:43:36 -0800 Subject: [PATCH 2/3] cp dines --- src/commands/blueprint/prune.ts | 20 +++++++++++--------- src/utils/commands.ts | 4 +++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts index e479615a..c070f6c4 100644 --- a/src/commands/blueprint/prune.ts +++ b/src/commands/blueprint/prune.ts @@ -28,9 +28,7 @@ interface PruneResult { /** * Fetch all blueprints with a given name (handles pagination) */ -async function fetchAllBlueprintsWithName( - name: string, -): Promise { +async function fetchAllBlueprintsWithName(name: string): Promise { const client = getClient(); const allBlueprints: Blueprint[] = []; let hasMore = true; @@ -132,8 +130,12 @@ function displaySummary( console.log(`\nAnalyzing blueprints named "${name}"...`); console.log(`\nFound ${total} blueprint${total !== 1 ? "s" : ""}:`); - console.log(` ✓ ${result.successful.length} successful build${result.successful.length !== 1 ? "s" : ""}`); - console.log(` ✗ ${result.failed.length} failed build${result.failed.length !== 1 ? "s" : ""}`); + console.log( + ` ✓ ${result.successful.length} successful build${result.successful.length !== 1 ? "s" : ""}`, + ); + console.log( + ` ✗ ${result.failed.length} failed build${result.failed.length !== 1 ? "s" : ""}`, + ); // Show what will be kept console.log(`\nKeeping (${result.toKeep.length} most recent successful):`); @@ -213,9 +215,7 @@ async function confirmDeletion(count: number): Promise { `\nDelete ${count} blueprint${count !== 1 ? "s" : ""}? (y/N): `, (answer) => { rl.close(); - resolve( - answer.toLowerCase() === "y" || answer.toLowerCase() === "yes", - ); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); }, ); }); @@ -322,7 +322,9 @@ export async function pruneBlueprints( } // Perform deletions - console.log(`\nDeleting ${categorized.toDelete.length} blueprint${categorized.toDelete.length !== 1 ? "s" : ""}...`); + console.log( + `\nDeleting ${categorized.toDelete.length} blueprint${categorized.toDelete.length !== 1 ? "s" : ""}...`, + ); const deletionResults = await deleteBlueprintsWithTracking( categorized.toDelete, ); diff --git a/src/utils/commands.ts b/src/utils/commands.ts index a38c210a..d0df281f 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -459,7 +459,9 @@ export function createProgram(): Command { blueprint .command("prune ") - .description("Delete old blueprint builds, keeping only recent successful ones") + .description( + "Delete old blueprint builds, keeping only recent successful ones", + ) .option("--dry-run", "Show what would be deleted without actually deleting") .option("-y, --yes", "Skip confirmation prompt") .option("--keep ", "Number of successful builds to keep", "1") From 97378a96f1ed72e902c4fffdc79191117ad17614 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Fri, 16 Jan 2026 16:43:53 -0800 Subject: [PATCH 3/3] cp dines --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 37792951..bf220331 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ rli blueprint list # List all blueprints rli blueprint create # Create a new blueprint rli blueprint get # Get blueprint details by name or ID (... rli blueprint logs # Get blueprint build logs by name or I... +rli blueprint prune # Delete old blueprint builds, keeping ... ``` ### Object Commands (alias: `obj`)