@runloop/rl-cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,7 @@ rli blueprint list # List all blueprints
106
106
  rli blueprint create # Create a new blueprint
107
107
  rli blueprint get <name-or-id> # Get blueprint details by name or ID (...
108
108
  rli blueprint logs <name-or-id> # Get blueprint build logs by name or I...
109
+ rli blueprint prune <name> # Delete old blueprint builds, keeping ...
109
110
  ```
110
111
 
111
112
  ### Object Commands (alias: `obj`)
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Blueprint prune command - Delete old blueprint builds
3
+ */
4
+ import * as readline from "readline";
5
+ import { getClient } from "../../utils/client.js";
6
+ import { output, outputError } from "../../utils/output.js";
7
+ /**
8
+ * Fetch all blueprints with a given name (handles pagination)
9
+ */
10
+ async function fetchAllBlueprintsWithName(name) {
11
+ const client = getClient();
12
+ const allBlueprints = [];
13
+ let hasMore = true;
14
+ let startingAfter = undefined;
15
+ while (hasMore) {
16
+ const params = { name, limit: 100 };
17
+ if (startingAfter) {
18
+ params.starting_after = startingAfter;
19
+ }
20
+ try {
21
+ const page = await client.blueprints.list(params);
22
+ const blueprints = (page.blueprints || []);
23
+ allBlueprints.push(...blueprints);
24
+ hasMore = page.has_more || false;
25
+ if (hasMore && blueprints.length > 0) {
26
+ startingAfter = blueprints[blueprints.length - 1].id;
27
+ }
28
+ else {
29
+ hasMore = false;
30
+ }
31
+ }
32
+ catch (error) {
33
+ console.error("Warning: Error fetching blueprints:", error);
34
+ // Continue with partial results
35
+ hasMore = false;
36
+ }
37
+ }
38
+ return allBlueprints;
39
+ }
40
+ /**
41
+ * Categorize blueprints into successful and failed, and determine what to keep/delete
42
+ */
43
+ function categorizeBlueprints(blueprints, keepCount) {
44
+ // Filter successful builds
45
+ const successful = blueprints.filter((b) => b.status === "build_complete" || b.status === "building_complete");
46
+ // Filter failed builds
47
+ const failed = blueprints.filter((b) => b.status !== "build_complete" && b.status !== "building_complete");
48
+ // Sort successful by create_time_ms descending (newest first)
49
+ successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0));
50
+ // Determine what to keep and delete
51
+ const toKeep = successful.slice(0, keepCount);
52
+ const toDelete = [...successful.slice(keepCount), ...failed];
53
+ return {
54
+ toKeep,
55
+ toDelete,
56
+ successful,
57
+ failed,
58
+ };
59
+ }
60
+ /**
61
+ * Format a timestamp for display
62
+ */
63
+ function formatTimestamp(createTimeMs) {
64
+ if (!createTimeMs) {
65
+ return "unknown time";
66
+ }
67
+ const now = Date.now();
68
+ const diffMs = now - createTimeMs;
69
+ const diffMinutes = Math.floor(diffMs / 60000);
70
+ const diffHours = Math.floor(diffMs / 3600000);
71
+ const diffDays = Math.floor(diffMs / 86400000);
72
+ if (diffMinutes < 60) {
73
+ return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
74
+ }
75
+ else if (diffHours < 24) {
76
+ return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
77
+ }
78
+ else {
79
+ return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
80
+ }
81
+ }
82
+ /**
83
+ * Display a summary of what will be kept and deleted
84
+ */
85
+ function displaySummary(name, result, isDryRun) {
86
+ const total = result.successful.length + result.failed.length;
87
+ console.log(`\nAnalyzing blueprints named "${name}"...`);
88
+ console.log(`\nFound ${total} blueprint${total !== 1 ? "s" : ""}:`);
89
+ console.log(` ✓ ${result.successful.length} successful build${result.successful.length !== 1 ? "s" : ""}`);
90
+ console.log(` ✗ ${result.failed.length} failed build${result.failed.length !== 1 ? "s" : ""}`);
91
+ // Show what will be kept
92
+ console.log(`\nKeeping (${result.toKeep.length} most recent successful):`);
93
+ if (result.toKeep.length === 0) {
94
+ console.log(" (none - no successful builds found)");
95
+ }
96
+ else {
97
+ for (const blueprint of result.toKeep) {
98
+ console.log(` ✓ ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)}`);
99
+ }
100
+ }
101
+ // Show what will be deleted
102
+ console.log(`\n${isDryRun ? "Would delete" : "To be deleted"} (${result.toDelete.length} blueprint${result.toDelete.length !== 1 ? "s" : ""}):`);
103
+ if (result.toDelete.length === 0) {
104
+ console.log(" (none)");
105
+ }
106
+ else {
107
+ // Show all blueprints without summarizing
108
+ for (const blueprint of result.toDelete) {
109
+ const icon = blueprint.status === "build_complete" ||
110
+ blueprint.status === "building_complete"
111
+ ? "✓"
112
+ : "✗";
113
+ const statusLabel = blueprint.status === "build_complete" ||
114
+ blueprint.status === "building_complete"
115
+ ? "successful"
116
+ : "failed";
117
+ console.log(` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`);
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Display all deleted blueprints
123
+ */
124
+ function displayDeletedBlueprints(deleted) {
125
+ if (deleted.length === 0) {
126
+ return;
127
+ }
128
+ console.log("\nDeleted blueprints:");
129
+ for (const blueprint of deleted) {
130
+ const icon = blueprint.status === "build_complete" ||
131
+ blueprint.status === "building_complete"
132
+ ? "✓"
133
+ : "✗";
134
+ const statusLabel = blueprint.status === "build_complete" ||
135
+ blueprint.status === "building_complete"
136
+ ? "successful"
137
+ : "failed";
138
+ console.log(` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`);
139
+ }
140
+ }
141
+ /**
142
+ * Prompt user for confirmation
143
+ */
144
+ async function confirmDeletion(count) {
145
+ const rl = readline.createInterface({
146
+ input: process.stdin,
147
+ output: process.stdout,
148
+ });
149
+ return new Promise((resolve) => {
150
+ rl.question(`\nDelete ${count} blueprint${count !== 1 ? "s" : ""}? (y/N): `, (answer) => {
151
+ rl.close();
152
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
153
+ });
154
+ });
155
+ }
156
+ /**
157
+ * Delete blueprints with error tracking
158
+ */
159
+ async function deleteBlueprintsWithTracking(blueprints) {
160
+ const client = getClient();
161
+ const results = {
162
+ deleted: [],
163
+ failed: [],
164
+ };
165
+ for (const blueprint of blueprints) {
166
+ try {
167
+ await client.blueprints.delete(blueprint.id);
168
+ results.deleted.push(blueprint);
169
+ }
170
+ catch (error) {
171
+ results.failed.push({
172
+ id: blueprint.id,
173
+ error: error instanceof Error ? error.message : String(error),
174
+ });
175
+ }
176
+ }
177
+ return results;
178
+ }
179
+ /**
180
+ * Main prune function
181
+ */
182
+ export async function pruneBlueprints(name, options = {}) {
183
+ try {
184
+ // Parse and validate options
185
+ const isDryRun = options.dryRun || false;
186
+ const autoConfirm = options.yes || false;
187
+ const keepCount = parseInt(options.keep || "1", 10);
188
+ if (isNaN(keepCount) || keepCount < 1) {
189
+ outputError("--keep must be a positive integer");
190
+ }
191
+ // Fetch all blueprints with the given name
192
+ console.log(`Fetching blueprints named "${name}"...`);
193
+ const blueprints = await fetchAllBlueprintsWithName(name);
194
+ // Handle no blueprints found
195
+ if (blueprints.length === 0) {
196
+ console.log(`No blueprints found with name: ${name}`);
197
+ return;
198
+ }
199
+ // Categorize blueprints
200
+ const categorized = categorizeBlueprints(blueprints, keepCount);
201
+ // Display summary
202
+ displaySummary(name, categorized, isDryRun);
203
+ // Handle dry-run mode
204
+ if (isDryRun) {
205
+ console.log("\n(Dry run - no changes made)");
206
+ const result = {
207
+ blueprintName: name,
208
+ totalFound: blueprints.length,
209
+ successfulBuilds: categorized.successful.length,
210
+ failedBuilds: categorized.failed.length,
211
+ kept: categorized.toKeep,
212
+ deleted: [],
213
+ failed: [],
214
+ dryRun: true,
215
+ };
216
+ if (options.output && options.output !== "text") {
217
+ output(result, { format: options.output, defaultFormat: "json" });
218
+ }
219
+ return;
220
+ }
221
+ // Handle nothing to delete
222
+ if (categorized.toDelete.length === 0) {
223
+ console.log("\nNothing to delete.");
224
+ return;
225
+ }
226
+ // Warn if no successful builds
227
+ if (categorized.successful.length === 0) {
228
+ console.log("\nWarning: No successful builds found. Only deleting failed builds.");
229
+ }
230
+ // Get confirmation unless --yes flag is set
231
+ if (!autoConfirm) {
232
+ const confirmed = await confirmDeletion(categorized.toDelete.length);
233
+ if (!confirmed) {
234
+ console.log("\nOperation cancelled.");
235
+ return;
236
+ }
237
+ }
238
+ // Perform deletions
239
+ console.log(`\nDeleting ${categorized.toDelete.length} blueprint${categorized.toDelete.length !== 1 ? "s" : ""}...`);
240
+ const deletionResults = await deleteBlueprintsWithTracking(categorized.toDelete);
241
+ // Display results
242
+ console.log("\nResults:");
243
+ console.log(` ✓ Successfully deleted: ${deletionResults.deleted.length} blueprint${deletionResults.deleted.length !== 1 ? "s" : ""}`);
244
+ // Show all deleted blueprints
245
+ displayDeletedBlueprints(deletionResults.deleted);
246
+ if (deletionResults.failed.length > 0) {
247
+ console.log(`\n ✗ Failed to delete: ${deletionResults.failed.length} blueprint${deletionResults.failed.length !== 1 ? "s" : ""}`);
248
+ for (const failure of deletionResults.failed) {
249
+ console.log(` - ${failure.id}: ${failure.error}`);
250
+ }
251
+ }
252
+ // Output structured data if requested
253
+ if (options.output && options.output !== "text") {
254
+ const result = {
255
+ blueprintName: name,
256
+ totalFound: blueprints.length,
257
+ successfulBuilds: categorized.successful.length,
258
+ failedBuilds: categorized.failed.length,
259
+ kept: categorized.toKeep,
260
+ deleted: deletionResults.deleted,
261
+ failed: deletionResults.failed,
262
+ dryRun: false,
263
+ };
264
+ output(result, { format: options.output, defaultFormat: "json" });
265
+ }
266
+ }
267
+ catch (error) {
268
+ outputError("Failed to prune blueprints", error);
269
+ }
270
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hook to handle Ctrl+C (SIGINT) consistently across all screens
2
+ * Hook to handle Ctrl+C (SIGINT) and Ctrl+D (EOF) consistently across all screens
3
3
  * Exits the program with proper cleanup of alternate screen buffer
4
4
  */
5
5
  import { useInput } from "ink";
@@ -11,5 +11,10 @@ export function useExitOnCtrlC() {
11
11
  exitAlternateScreenBuffer();
12
12
  processUtils.exit(130); // Standard exit code for SIGINT
13
13
  }
14
+ // Handle Ctrl+D (EOF) same as Ctrl+C
15
+ if (key.ctrl && input === "d") {
16
+ exitAlternateScreenBuffer();
17
+ processUtils.exit(0); // Clean exit for EOF
18
+ }
14
19
  });
15
20
  }
@@ -40,7 +40,7 @@ export function SSHSessionScreen() {
40
40
  "UserKnownHostsFile=/dev/null",
41
41
  `${sshUser}@${url}`,
42
42
  ], [keyPath, proxyCommand, sshUser, url]);
43
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "SSH Session", active: true }] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Connecting to ", devboxName, "..."] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press Ctrl+C or type exit to disconnect" })] }), _jsx(InteractiveSpawn, { command: "ssh", args: sshArgs, onExit: (_code) => {
43
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "SSH Session", active: true }] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Connecting to ", devboxName, "..."] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press Ctrl+C, Ctrl+D, or type exit to disconnect" })] }), _jsx(InteractiveSpawn, { command: "ssh", args: sshArgs, onExit: (_code) => {
44
44
  // Replace current screen (don't add SSH to history stack)
45
45
  // Using replace() instead of navigate() prevents "escape goes back to SSH" bug
46
46
  setTimeout(() => {
@@ -300,6 +300,17 @@ export function createProgram() {
300
300
  const { getBlueprintLogs } = await import("../commands/blueprint/logs.js");
301
301
  await getBlueprintLogs({ id, ...options });
302
302
  });
303
+ blueprint
304
+ .command("prune <name>")
305
+ .description("Delete old blueprint builds, keeping only recent successful ones")
306
+ .option("--dry-run", "Show what would be deleted without actually deleting")
307
+ .option("-y, --yes", "Skip confirmation prompt")
308
+ .option("--keep <n>", "Number of successful builds to keep", "1")
309
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
310
+ .action(async (name, options) => {
311
+ const { pruneBlueprints } = await import("../commands/blueprint/prune.js");
312
+ await pruneBlueprints(name, options);
313
+ });
303
314
  // Object storage commands
304
315
  const object = program
305
316
  .command("object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {