diff --git a/README.md b/README.md index 4f0c7345..9b72dbc6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,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 delete # Delete a blueprint by ID rli blueprint prune # Delete old blueprint builds, keeping ... rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ... ``` diff --git a/src/commands/blueprint/delete.ts b/src/commands/blueprint/delete.ts new file mode 100644 index 00000000..48767150 --- /dev/null +++ b/src/commands/blueprint/delete.ts @@ -0,0 +1,30 @@ +/** + * Delete blueprint command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface DeleteOptions { + output?: string; +} + +export async function deleteBlueprint(id: string, options: DeleteOptions = {}) { + try { + const client = getClient(); + + await client.blueprints.delete(id); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(id); + } else { + output( + { id, status: "deleted" }, + { format: options.output, defaultFormat: "json" }, + ); + } + } catch (error) { + outputError("Failed to delete blueprint", error); + } +} diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts index 46e90909..10107999 100644 --- a/src/commands/blueprint/prune.ts +++ b/src/commands/blueprint/prune.ts @@ -75,6 +75,7 @@ function categorizeBlueprints(blueprints: Blueprint[], keepCount: number) { successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0)); // Determine what to keep and delete + // keepCount of 0 means delete all (including successful builds) const toKeep = successful.slice(0, keepCount); const toDelete = [...successful.slice(keepCount), ...failed]; @@ -136,7 +137,11 @@ function displaySummary( // 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)"); + if (result.successful.length === 0) { + console.log(" (none - no successful builds found)"); + } else { + console.log(" (none)"); + } } else { for (const blueprint of result.toKeep) { console.log( @@ -241,8 +246,8 @@ export async function pruneBlueprints( const autoConfirm = options.yes || false; const keepCount = parseInt(options.keep || "1", 10); - if (isNaN(keepCount) || keepCount < 1) { - outputError("--keep must be a positive integer"); + if (isNaN(keepCount) || keepCount < 0) { + outputError("--keep must be a non-negative integer"); } // Fetch all blueprints with the given name diff --git a/src/utils/commands.ts b/src/utils/commands.ts index f3dc0007..60ed5f29 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -468,6 +468,20 @@ export function createProgram(): Command { await getBlueprintLogs({ id, ...options }); }); + blueprint + .command("delete ") + .description("Delete a blueprint by ID") + .alias("rm") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (id, options) => { + const { deleteBlueprint } = + await import("../commands/blueprint/delete.js"); + await deleteBlueprint(id, options); + }); + blueprint .command("prune ") .description( diff --git a/tests/__tests__/commands/blueprint/delete.test.ts b/tests/__tests__/commands/blueprint/delete.test.ts new file mode 100644 index 00000000..8e91603a --- /dev/null +++ b/tests/__tests__/commands/blueprint/delete.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for blueprint delete command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +// Mock dependencies using the path alias +const mockDelete = jest.fn(); +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + blueprints: { + delete: mockDelete, + }, + }), +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +describe("deleteBlueprint", () => { + beforeEach(() => { + jest.clearAllMocks(); + (console.log as jest.Mock).mockClear(); + mockDelete.mockReset(); + mockOutput.mockReset(); + mockOutputError.mockReset(); + }); + + it("should delete a blueprint by ID", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_abc123", {}); + + expect(mockDelete).toHaveBeenCalledWith("bpt_abc123"); + expect(console.log).toHaveBeenCalledWith("bpt_abc123"); + }); + + it("should output JSON format when requested", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_json123", { output: "json" }); + + expect(mockDelete).toHaveBeenCalledWith("bpt_json123"); + expect(mockOutput).toHaveBeenCalledWith( + { id: "bpt_json123", status: "deleted" }, + { format: "json", defaultFormat: "json" }, + ); + expect(console.log).not.toHaveBeenCalledWith("bpt_json123"); + }); + + it("should output YAML format when requested", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_yaml456", { output: "yaml" }); + + expect(mockDelete).toHaveBeenCalledWith("bpt_yaml456"); + expect(mockOutput).toHaveBeenCalledWith( + { id: "bpt_yaml456", status: "deleted" }, + { format: "yaml", defaultFormat: "json" }, + ); + }); + + it("should output just the ID in text format (default)", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_text789", { output: "text" }); + + expect(console.log).toHaveBeenCalledWith("bpt_text789"); + expect(mockOutput).not.toHaveBeenCalled(); + }); + + it("should output just the ID when no output option is provided", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_default", {}); + + expect(console.log).toHaveBeenCalledWith("bpt_default"); + expect(mockOutput).not.toHaveBeenCalled(); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error: Forbidden"); + mockDelete.mockRejectedValue(apiError); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_error", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to delete blueprint", + apiError, + ); + }); + + it("should handle dependent snapshot errors gracefully", async () => { + const apiError = new Error( + "Blueprint has dependent snapshots and cannot be deleted", + ); + mockDelete.mockRejectedValue(apiError); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_has_snapshots", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to delete blueprint", + apiError, + ); + }); +});