Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ rli blueprint list # List all blueprints
rli blueprint create # Create a new blueprint
rli blueprint get <name-or-id> # Get blueprint details by name or ID (...
rli blueprint logs <name-or-id> # Get blueprint build logs by name or I...
rli blueprint delete <id> # Delete a blueprint by ID
rli blueprint prune <name> # Delete old blueprint builds, keeping ...
rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ...
```
Expand Down
30 changes: 30 additions & 0 deletions src/commands/blueprint/delete.ts
Original file line number Diff line number Diff line change
@@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just as a suggestion, maybe it would be good to add a decorator to wire in the client

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed name as it doesn't actually make a lot of sense, delete the most recent or all of them ect


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);
}
}
11 changes: 8 additions & 3 deletions src/commands/blueprint/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/utils/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,20 @@ export function createProgram(): Command {
await getBlueprintLogs({ id, ...options });
});

blueprint
.command("delete <id>")
.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 <name>")
.description(
Expand Down
131 changes: 131 additions & 0 deletions tests/__tests__/commands/blueprint/delete.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading