From 6ce100b674fdec18f3dcfc1ecd81264b914b45a1 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 11:22:37 -0800 Subject: [PATCH 1/6] cp dines --- src/commands/blueprint/delete.ts | 55 +++++ src/utils/commands.ts | 14 ++ .../commands/blueprint/delete.test.ts | 233 ++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 src/commands/blueprint/delete.ts create mode 100644 tests/__tests__/commands/blueprint/delete.test.ts diff --git a/src/commands/blueprint/delete.ts b/src/commands/blueprint/delete.ts new file mode 100644 index 00000000..e548d92e --- /dev/null +++ b/src/commands/blueprint/delete.ts @@ -0,0 +1,55 @@ +/** + * Delete blueprint command + * Supports both blueprint ID (bpt_...) and name + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface DeleteOptions { + output?: string; +} + +export async function deleteBlueprint( + nameOrId: string, + options: DeleteOptions = {}, +) { + try { + const client = getClient(); + + let blueprintId = nameOrId; + + // If it's not an ID, resolve by name + if (!nameOrId.startsWith("bpt_")) { + const result = await client.blueprints.list({ name: nameOrId }); + const blueprints = result.blueprints || []; + + if (blueprints.length === 0) { + outputError( + `Blueprint not found: ${nameOrId}`, + new Error("Blueprint not found"), + ); + return; + } + + // Use exact match if available, otherwise first result + const blueprint = + blueprints.find((b) => b.name === nameOrId) || blueprints[0]; + blueprintId = blueprint.id; + } + + await client.blueprints.delete(blueprintId); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(blueprintId); + } else { + output( + { id: blueprintId, status: "deleted" }, + { format: options.output, defaultFormat: "json" }, + ); + } + } catch (error) { + outputError("Failed to delete blueprint", error); + } +} diff --git a/src/utils/commands.ts b/src/utils/commands.ts index f3dc0007..a268819c 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 name or ID (IDs start with bpt_)") + .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..6e3a3174 --- /dev/null +++ b/tests/__tests__/commands/blueprint/delete.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for blueprint delete command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +// Mock dependencies using the path alias +const mockDelete = jest.fn(); +const mockList = jest.fn(); +jest.unstable_mockModule("@/utils/client.js", () => ({ + getClient: () => ({ + blueprints: { + delete: mockDelete, + list: mockList, + }, + }), +})); + +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(); + mockList.mockReset(); + mockOutput.mockReset(); + mockOutputError.mockReset(); + }); + + it("should delete a blueprint by ID directly", async () => { + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("bpt_abc123", {}); + + expect(mockList).not.toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith("bpt_abc123"); + expect(console.log).toHaveBeenCalledWith("bpt_abc123"); + }); + + it("should resolve blueprint by name and delete", async () => { + mockList.mockResolvedValue({ + blueprints: [{ id: "bpt_resolved", name: "my-blueprint" }], + }); + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("my-blueprint", {}); + + expect(mockList).toHaveBeenCalledWith({ name: "my-blueprint" }); + expect(mockDelete).toHaveBeenCalledWith("bpt_resolved"); + expect(console.log).toHaveBeenCalledWith("bpt_resolved"); + }); + + it("should prefer exact name match when resolving by name", async () => { + mockList.mockResolvedValue({ + blueprints: [ + { id: "bpt_partial", name: "my-blueprint-v2" }, + { id: "bpt_exact", name: "my-blueprint" }, + ], + }); + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("my-blueprint", {}); + + expect(mockDelete).toHaveBeenCalledWith("bpt_exact"); + expect(console.log).toHaveBeenCalledWith("bpt_exact"); + }); + + it("should fall back to first result when no exact name match", async () => { + mockList.mockResolvedValue({ + blueprints: [ + { id: "bpt_first", name: "my-blueprint-v1" }, + { id: "bpt_second", name: "my-blueprint-v2" }, + ], + }); + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("my-blueprint", {}); + + expect(mockDelete).toHaveBeenCalledWith("bpt_first"); + expect(console.log).toHaveBeenCalledWith("bpt_first"); + }); + + it("should output error when blueprint name is not found", async () => { + mockList.mockResolvedValue({ blueprints: [] }); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("nonexistent-blueprint", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Blueprint not found: nonexistent-blueprint", + expect.any(Error), + ); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it("should handle empty blueprints array from API", async () => { + mockList.mockResolvedValue({}); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("nonexistent", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Blueprint not found: nonexistent", + expect.any(Error), + ); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + 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 on delete 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 API errors on list gracefully", async () => { + const apiError = new Error("API Error: Network failure"); + mockList.mockRejectedValue(apiError); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("some-name", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to delete blueprint", + apiError, + ); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it("should output resolved ID in text format when deleting by name", async () => { + mockList.mockResolvedValue({ + blueprints: [{ id: "bpt_resolved_id", name: "named-blueprint" }], + }); + mockDelete.mockResolvedValue(undefined); + + const { deleteBlueprint } = await import( + "@/commands/blueprint/delete.js" + ); + await deleteBlueprint("named-blueprint", { output: "json" }); + + expect(mockOutput).toHaveBeenCalledWith( + { id: "bpt_resolved_id", status: "deleted" }, + { format: "json", defaultFormat: "json" }, + ); + }); +}); From 4cc2cb9d09af7a3054837dfd944676ceda8055ca Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 11:22:52 -0800 Subject: [PATCH 2/6] cp dines --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4f0c7345..4dea7ec9 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 name or ID (IDs... rli blueprint prune # Delete old blueprint builds, keeping ... rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ... ``` From fca093c27120e0a7dd0b77fd810bd302544fc30b Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 11:25:04 -0800 Subject: [PATCH 3/6] cp dines --- src/commands/blueprint/prune.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 From 485b3cf9fe118ac04ab200606a0c92d935315a2b Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 12:43:34 -0800 Subject: [PATCH 4/6] cp dines --- src/commands/blueprint/delete.ts | 30 +---- src/utils/commands.ts | 4 +- .../commands/blueprint/delete.test.ts | 118 ++---------------- 3 files changed, 14 insertions(+), 138 deletions(-) diff --git a/src/commands/blueprint/delete.ts b/src/commands/blueprint/delete.ts index e548d92e..97d2d6ea 100644 --- a/src/commands/blueprint/delete.ts +++ b/src/commands/blueprint/delete.ts @@ -1,6 +1,5 @@ /** * Delete blueprint command - * Supports both blueprint ID (bpt_...) and name */ import { getClient } from "../../utils/client.js"; @@ -11,41 +10,20 @@ interface DeleteOptions { } export async function deleteBlueprint( - nameOrId: string, + id: string, options: DeleteOptions = {}, ) { try { const client = getClient(); - let blueprintId = nameOrId; - - // If it's not an ID, resolve by name - if (!nameOrId.startsWith("bpt_")) { - const result = await client.blueprints.list({ name: nameOrId }); - const blueprints = result.blueprints || []; - - if (blueprints.length === 0) { - outputError( - `Blueprint not found: ${nameOrId}`, - new Error("Blueprint not found"), - ); - return; - } - - // Use exact match if available, otherwise first result - const blueprint = - blueprints.find((b) => b.name === nameOrId) || blueprints[0]; - blueprintId = blueprint.id; - } - - await client.blueprints.delete(blueprintId); + await client.blueprints.delete(id); // Default: just output the ID for easy scripting if (!options.output || options.output === "text") { - console.log(blueprintId); + console.log(id); } else { output( - { id: blueprintId, status: "deleted" }, + { id, status: "deleted" }, { format: options.output, defaultFormat: "json" }, ); } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index a268819c..60ed5f29 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -469,8 +469,8 @@ export function createProgram(): Command { }); blueprint - .command("delete ") - .description("Delete a blueprint by name or ID (IDs start with bpt_)") + .command("delete ") + .description("Delete a blueprint by ID") .alias("rm") .option( "-o, --output [format]", diff --git a/tests/__tests__/commands/blueprint/delete.test.ts b/tests/__tests__/commands/blueprint/delete.test.ts index 6e3a3174..8e91603a 100644 --- a/tests/__tests__/commands/blueprint/delete.test.ts +++ b/tests/__tests__/commands/blueprint/delete.test.ts @@ -6,12 +6,10 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; // Mock dependencies using the path alias const mockDelete = jest.fn(); -const mockList = jest.fn(); jest.unstable_mockModule("@/utils/client.js", () => ({ getClient: () => ({ blueprints: { delete: mockDelete, - list: mockList, }, }), })); @@ -28,12 +26,11 @@ describe("deleteBlueprint", () => { jest.clearAllMocks(); (console.log as jest.Mock).mockClear(); mockDelete.mockReset(); - mockList.mockReset(); mockOutput.mockReset(); mockOutputError.mockReset(); }); - it("should delete a blueprint by ID directly", async () => { + it("should delete a blueprint by ID", async () => { mockDelete.mockResolvedValue(undefined); const { deleteBlueprint } = await import( @@ -41,93 +38,10 @@ describe("deleteBlueprint", () => { ); await deleteBlueprint("bpt_abc123", {}); - expect(mockList).not.toHaveBeenCalled(); expect(mockDelete).toHaveBeenCalledWith("bpt_abc123"); expect(console.log).toHaveBeenCalledWith("bpt_abc123"); }); - it("should resolve blueprint by name and delete", async () => { - mockList.mockResolvedValue({ - blueprints: [{ id: "bpt_resolved", name: "my-blueprint" }], - }); - mockDelete.mockResolvedValue(undefined); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("my-blueprint", {}); - - expect(mockList).toHaveBeenCalledWith({ name: "my-blueprint" }); - expect(mockDelete).toHaveBeenCalledWith("bpt_resolved"); - expect(console.log).toHaveBeenCalledWith("bpt_resolved"); - }); - - it("should prefer exact name match when resolving by name", async () => { - mockList.mockResolvedValue({ - blueprints: [ - { id: "bpt_partial", name: "my-blueprint-v2" }, - { id: "bpt_exact", name: "my-blueprint" }, - ], - }); - mockDelete.mockResolvedValue(undefined); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("my-blueprint", {}); - - expect(mockDelete).toHaveBeenCalledWith("bpt_exact"); - expect(console.log).toHaveBeenCalledWith("bpt_exact"); - }); - - it("should fall back to first result when no exact name match", async () => { - mockList.mockResolvedValue({ - blueprints: [ - { id: "bpt_first", name: "my-blueprint-v1" }, - { id: "bpt_second", name: "my-blueprint-v2" }, - ], - }); - mockDelete.mockResolvedValue(undefined); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("my-blueprint", {}); - - expect(mockDelete).toHaveBeenCalledWith("bpt_first"); - expect(console.log).toHaveBeenCalledWith("bpt_first"); - }); - - it("should output error when blueprint name is not found", async () => { - mockList.mockResolvedValue({ blueprints: [] }); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("nonexistent-blueprint", {}); - - expect(mockOutputError).toHaveBeenCalledWith( - "Blueprint not found: nonexistent-blueprint", - expect.any(Error), - ); - expect(mockDelete).not.toHaveBeenCalled(); - }); - - it("should handle empty blueprints array from API", async () => { - mockList.mockResolvedValue({}); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("nonexistent", {}); - - expect(mockOutputError).toHaveBeenCalledWith( - "Blueprint not found: nonexistent", - expect.any(Error), - ); - expect(mockDelete).not.toHaveBeenCalled(); - }); - it("should output JSON format when requested", async () => { mockDelete.mockResolvedValue(undefined); @@ -183,7 +97,7 @@ describe("deleteBlueprint", () => { expect(mockOutput).not.toHaveBeenCalled(); }); - it("should handle API errors on delete gracefully", async () => { + it("should handle API errors gracefully", async () => { const apiError = new Error("API Error: Forbidden"); mockDelete.mockRejectedValue(apiError); @@ -198,36 +112,20 @@ describe("deleteBlueprint", () => { ); }); - it("should handle API errors on list gracefully", async () => { - const apiError = new Error("API Error: Network failure"); - mockList.mockRejectedValue(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("some-name", {}); + await deleteBlueprint("bpt_has_snapshots", {}); expect(mockOutputError).toHaveBeenCalledWith( "Failed to delete blueprint", apiError, ); - expect(mockDelete).not.toHaveBeenCalled(); - }); - - it("should output resolved ID in text format when deleting by name", async () => { - mockList.mockResolvedValue({ - blueprints: [{ id: "bpt_resolved_id", name: "named-blueprint" }], - }); - mockDelete.mockResolvedValue(undefined); - - const { deleteBlueprint } = await import( - "@/commands/blueprint/delete.js" - ); - await deleteBlueprint("named-blueprint", { output: "json" }); - - expect(mockOutput).toHaveBeenCalledWith( - { id: "bpt_resolved_id", status: "deleted" }, - { format: "json", defaultFormat: "json" }, - ); }); }); From a611445a9f89d0f120a7d81eb2c2e0ef4960c1d8 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 12:43:44 -0800 Subject: [PATCH 5/6] cp dines --- src/commands/blueprint/delete.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/blueprint/delete.ts b/src/commands/blueprint/delete.ts index 97d2d6ea..48767150 100644 --- a/src/commands/blueprint/delete.ts +++ b/src/commands/blueprint/delete.ts @@ -9,10 +9,7 @@ interface DeleteOptions { output?: string; } -export async function deleteBlueprint( - id: string, - options: DeleteOptions = {}, -) { +export async function deleteBlueprint(id: string, options: DeleteOptions = {}) { try { const client = getClient(); From fbe30773ae7edf38239163d29d3affc5f5f8c9d1 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 9 Feb 2026 12:44:03 -0800 Subject: [PATCH 6/6] cp dines --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4dea7ec9..9b72dbc6 100644 --- a/README.md +++ b/README.md @@ -119,7 +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 name or ID (IDs... +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 ... ```