@runloop/rl-cli 1.3.0 → 1.4.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
@@ -118,6 +118,7 @@ rli blueprint create # Create a new blueprint
118
118
  rli blueprint get <name-or-id> # Get blueprint details by name or ID (...
119
119
  rli blueprint logs <name-or-id> # Get blueprint build logs by name or I...
120
120
  rli blueprint prune <name> # Delete old blueprint builds, keeping ...
121
+ rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ...
121
122
  ```
122
123
 
123
124
  ### Object Commands (alias: `obj`)
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Create blueprint from Dockerfile command
3
+ *
4
+ * Manually handles each step with progress feedback:
5
+ * 1. Upload build context as tarball
6
+ * 2. Create blueprint
7
+ * 3. Poll for build completion
8
+ */
9
+ import { readFile, stat } from "fs/promises";
10
+ import { resolve, join } from "path";
11
+ import { getClient } from "../../utils/client.js";
12
+ import { output, outputError } from "../../utils/output.js";
13
+ import { StorageObject } from "@runloop/api-client/sdk";
14
+ // Helper to check if we should show progress
15
+ function shouldShowProgress(options) {
16
+ return !options.output || options.output === "text";
17
+ }
18
+ // Helper to log progress (to stderr so it doesn't interfere with JSON output)
19
+ function logProgress(message, options) {
20
+ if (shouldShowProgress(options)) {
21
+ console.error(message);
22
+ }
23
+ }
24
+ // Helper to format elapsed time
25
+ function formatElapsed(startTime) {
26
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
27
+ if (elapsed < 60) {
28
+ return `${elapsed}s`;
29
+ }
30
+ const minutes = Math.floor(elapsed / 60);
31
+ const seconds = elapsed % 60;
32
+ return `${minutes}m ${seconds}s`;
33
+ }
34
+ export async function createBlueprintFromDockerfile(options) {
35
+ const startTime = Date.now();
36
+ try {
37
+ const client = getClient();
38
+ // Resolve build context path (defaults to current directory)
39
+ const buildContextPath = resolve(options.buildContext || ".");
40
+ // Verify build context exists and is a directory
41
+ try {
42
+ const stats = await stat(buildContextPath);
43
+ if (!stats.isDirectory()) {
44
+ outputError(`Build context path is not a directory: ${buildContextPath}`);
45
+ }
46
+ }
47
+ catch {
48
+ outputError(`Build context path does not exist: ${buildContextPath}`);
49
+ }
50
+ // Resolve Dockerfile path
51
+ const dockerfilePath = options.dockerfile
52
+ ? resolve(options.dockerfile)
53
+ : join(buildContextPath, "Dockerfile");
54
+ // Verify Dockerfile exists
55
+ try {
56
+ const stats = await stat(dockerfilePath);
57
+ if (!stats.isFile()) {
58
+ outputError(`Dockerfile path is not a file: ${dockerfilePath}`);
59
+ }
60
+ }
61
+ catch {
62
+ outputError(`Dockerfile not found: ${dockerfilePath}`);
63
+ }
64
+ // Log initial info
65
+ logProgress(`\nšŸ“¦ Creating blueprint "${options.name}" from Dockerfile`, options);
66
+ logProgress(` Build context: ${buildContextPath}`, options);
67
+ logProgress(` Dockerfile: ${dockerfilePath}\n`, options);
68
+ // Read the Dockerfile contents
69
+ const dockerfileContents = await readFile(dockerfilePath, "utf-8");
70
+ // Parse user parameters
71
+ let userParameters = undefined;
72
+ if (options.user && options.root) {
73
+ outputError("Only one of --user or --root can be specified");
74
+ }
75
+ else if (options.user) {
76
+ const [username, uid] = options.user.split(":");
77
+ if (!username || !uid) {
78
+ outputError("User must be in format 'username:uid'");
79
+ }
80
+ userParameters = { username, uid: parseInt(uid) };
81
+ }
82
+ else if (options.root) {
83
+ userParameters = { username: "root", uid: 0 };
84
+ }
85
+ // Build launch parameters
86
+ const launchParameters = {};
87
+ if (options.resources) {
88
+ launchParameters.resource_size_request = options.resources;
89
+ }
90
+ if (options.architecture) {
91
+ launchParameters.architecture = options.architecture;
92
+ }
93
+ if (options.availablePorts) {
94
+ launchParameters.available_ports = options.availablePorts.map((port) => parseInt(port, 10));
95
+ }
96
+ if (userParameters) {
97
+ launchParameters.user_parameters = userParameters;
98
+ }
99
+ // Parse TTL (default: 1 hour = 3600000ms)
100
+ const ttlMs = options.ttl ? parseInt(options.ttl) * 1000 : 3600000;
101
+ // Step 1: Upload build context
102
+ logProgress(`ā³ [1/3] Creating and uploading build context tarball...`, options);
103
+ const uploadStart = Date.now();
104
+ const storageObject = await StorageObject.uploadFromDir(client, buildContextPath, {
105
+ name: `build-context-${options.name}`,
106
+ ttl_ms: ttlMs,
107
+ });
108
+ logProgress(`āœ… [1/3] Build context uploaded (${formatElapsed(uploadStart)})`, options);
109
+ logProgress(` Object ID: ${storageObject.id}`, options);
110
+ // Step 2: Create the blueprint
111
+ logProgress(`\nā³ [2/3] Creating blueprint...`, options);
112
+ const createStart = Date.now();
113
+ const createParams = {
114
+ name: options.name,
115
+ dockerfile: dockerfileContents,
116
+ system_setup_commands: options.systemSetupCommands,
117
+ launch_parameters: launchParameters,
118
+ build_context: {
119
+ type: "object",
120
+ object_id: storageObject.id,
121
+ },
122
+ };
123
+ const blueprintResponse = await client.blueprints.create(createParams);
124
+ logProgress(`āœ… [2/3] Blueprint created (${formatElapsed(createStart)})`, options);
125
+ logProgress(` Blueprint ID: ${blueprintResponse.id}`, options);
126
+ // Step 3: Wait for build to complete (unless --no-wait)
127
+ if (options.noWait) {
128
+ logProgress(`\nā© Skipping build wait (--no-wait specified)`, options);
129
+ logProgress(` Check status with: rli blueprint get ${blueprintResponse.id}`, options);
130
+ logProgress(` View logs with: rli blueprint logs ${blueprintResponse.id}\n`, options);
131
+ output(blueprintResponse, {
132
+ format: options.output,
133
+ defaultFormat: "json",
134
+ });
135
+ return;
136
+ }
137
+ logProgress(`\nā³ [3/3] Waiting for build to complete...`, options);
138
+ const buildStart = Date.now();
139
+ let lastStatus = "";
140
+ let pollCount = 0;
141
+ // Poll for completion
142
+ while (true) {
143
+ const blueprint = await client.blueprints.retrieve(blueprintResponse.id);
144
+ const currentStatus = blueprint.status || "unknown";
145
+ // Log status changes
146
+ if (currentStatus !== lastStatus) {
147
+ const elapsed = formatElapsed(buildStart);
148
+ logProgress(` Status: ${currentStatus} (${elapsed})`, options);
149
+ lastStatus = currentStatus;
150
+ }
151
+ else if (pollCount % 10 === 0 && pollCount > 0) {
152
+ // Log periodic updates even without status change (every ~30s)
153
+ const elapsed = formatElapsed(buildStart);
154
+ logProgress(` Still ${currentStatus}... (${elapsed})`, options);
155
+ }
156
+ // Check for terminal states
157
+ if (blueprint.status === "build_complete") {
158
+ logProgress(`\nāœ… [3/3] Build completed successfully! (${formatElapsed(buildStart)})`, options);
159
+ logProgress(`\nšŸŽ‰ Blueprint "${options.name}" is ready!`, options);
160
+ logProgress(` Total time: ${formatElapsed(startTime)}`, options);
161
+ logProgress(` Blueprint ID: ${blueprint.id}\n`, options);
162
+ output(blueprint, { format: options.output, defaultFormat: "json" });
163
+ return;
164
+ }
165
+ if (blueprint.status === "failed") {
166
+ logProgress(`\nāŒ [3/3] Build failed (${formatElapsed(buildStart)})`, options);
167
+ if (blueprint.failure_reason) {
168
+ logProgress(` Reason: ${blueprint.failure_reason}`, options);
169
+ }
170
+ logProgress(` View logs with: rli blueprint logs ${blueprint.id}\n`, options);
171
+ outputError(`Blueprint build failed. Status: ${blueprint.status}`);
172
+ }
173
+ // Wait before next poll (3 seconds)
174
+ await new Promise((resolve) => setTimeout(resolve, 3000));
175
+ pollCount++;
176
+ }
177
+ }
178
+ catch (error) {
179
+ logProgress(`\nāŒ Failed after ${formatElapsed(startTime)}`, options);
180
+ outputError("Failed to create blueprint from Dockerfile", error);
181
+ }
182
+ }
@@ -109,6 +109,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
109
109
  // Select navigation handlers using shared hook
110
110
  const handleArchitectureNav = useFormSelectNavigation(formData.architecture, architectures, (value) => setFormData({ ...formData, architecture: value }), currentField === "architecture");
111
111
  const handleResourceSizeNav = useFormSelectNavigation(formData.resource_size || "SMALL", resourceSizes, (value) => setFormData({ ...formData, resource_size: value }), currentField === "resource_size");
112
+ // Main form input handler - active when not in metadata section
112
113
  useInput((input, key) => {
113
114
  // Handle result screen
114
115
  if (result) {
@@ -116,7 +117,9 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
116
117
  if (onCreate) {
117
118
  onCreate(result);
118
119
  }
119
- onBack();
120
+ else {
121
+ onBack();
122
+ }
120
123
  }
121
124
  return;
122
125
  }
@@ -136,118 +139,24 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
136
139
  if (creating) {
137
140
  return;
138
141
  }
139
- // Handle metadata section FIRST (before general escape handler)
140
- if (inMetadataSection) {
141
- const metadataKeys = Object.keys(formData.metadata);
142
- // Selection model: 0 = "Add new", 1..n = Existing items, n+1 = "Done"
143
- const maxIndex = metadataKeys.length + 1;
144
- // Handle input mode (typing key or value)
145
- if (metadataInputMode) {
146
- if (metadataInputMode === "key" && key.return && metadataKey.trim()) {
147
- setMetadataInputMode("value");
148
- return;
149
- }
150
- else if (metadataInputMode === "value" && key.return) {
151
- if (metadataKey.trim() && metadataValue.trim()) {
152
- setFormData({
153
- ...formData,
154
- metadata: {
155
- ...formData.metadata,
156
- [metadataKey.trim()]: metadataValue.trim(),
157
- },
158
- });
159
- }
160
- setMetadataKey("");
161
- setMetadataValue("");
162
- setMetadataInputMode(null);
163
- setSelectedMetadataIndex(0); // Back to "add new" row
164
- return;
165
- }
166
- else if (key.escape) {
167
- // Cancel input
168
- setMetadataKey("");
169
- setMetadataValue("");
170
- setMetadataInputMode(null);
171
- return;
172
- }
173
- else if (key.tab) {
174
- // Tab between key and value
175
- setMetadataInputMode(metadataInputMode === "key" ? "value" : "key");
176
- return;
177
- }
178
- return; // Don't process other keys while in input mode
179
- }
180
- // Navigation mode
181
- if (key.upArrow && selectedMetadataIndex > 0) {
182
- setSelectedMetadataIndex(selectedMetadataIndex - 1);
183
- }
184
- else if (key.downArrow && selectedMetadataIndex < maxIndex) {
185
- setSelectedMetadataIndex(selectedMetadataIndex + 1);
186
- }
187
- else if (key.return) {
188
- if (selectedMetadataIndex === 0) {
189
- // Add new
190
- setMetadataKey("");
191
- setMetadataValue("");
192
- setMetadataInputMode("key");
193
- }
194
- else if (selectedMetadataIndex === maxIndex) {
195
- // Done - exit metadata section
196
- setInMetadataSection(false);
197
- setSelectedMetadataIndex(0);
198
- setMetadataKey("");
199
- setMetadataValue("");
200
- setMetadataInputMode(null);
201
- }
202
- else if (selectedMetadataIndex >= 1 &&
203
- selectedMetadataIndex <= metadataKeys.length) {
204
- // Edit existing (selectedMetadataIndex - 1 gives array index)
205
- const keyToEdit = metadataKeys[selectedMetadataIndex - 1];
206
- setMetadataKey(keyToEdit || "");
207
- setMetadataValue(formData.metadata[keyToEdit] || "");
208
- // Remove old entry
209
- const newMetadata = { ...formData.metadata };
210
- delete newMetadata[keyToEdit];
211
- setFormData({ ...formData, metadata: newMetadata });
212
- setMetadataInputMode("key");
213
- }
214
- }
215
- else if ((input === "d" || key.delete) &&
216
- selectedMetadataIndex >= 1 &&
217
- selectedMetadataIndex <= metadataKeys.length) {
218
- // Delete selected item (selectedMetadataIndex - 1 gives array index)
219
- const keyToDelete = metadataKeys[selectedMetadataIndex - 1];
220
- const newMetadata = { ...formData.metadata };
221
- delete newMetadata[keyToDelete];
222
- setFormData({ ...formData, metadata: newMetadata });
223
- // Stay at same position or move to add new if we deleted the last item
224
- const newLength = Object.keys(newMetadata).length;
225
- if (selectedMetadataIndex > newLength) {
226
- setSelectedMetadataIndex(Math.max(0, newLength));
227
- }
228
- }
229
- else if (key.escape || input === "q") {
230
- // Exit metadata section
231
- setInMetadataSection(false);
232
- setSelectedMetadataIndex(0);
233
- setMetadataKey("");
234
- setMetadataValue("");
235
- setMetadataInputMode(null);
236
- }
237
- return;
238
- }
239
- // Back to list (only when not in metadata section)
142
+ // Back to list
240
143
  if (input === "q" || key.escape) {
241
144
  onBack();
242
145
  return;
243
146
  }
244
- // Submit form
147
+ // Submit form with Ctrl+S
245
148
  if (input === "s" && key.ctrl) {
246
149
  handleCreate();
247
150
  return;
248
151
  }
249
- // Handle Enter on create field
250
- if (currentField === "create" && key.return) {
152
+ // Enter key on metadata field to enter metadata section
153
+ if (currentField === "metadata" && key.return) {
154
+ setInMetadataSection(true);
155
+ setSelectedMetadataIndex(0);
156
+ return;
157
+ }
158
+ // Handle Enter on any field to submit
159
+ if (key.return) {
251
160
  handleCreate();
252
161
  return;
253
162
  }
@@ -266,13 +175,96 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
266
175
  setCurrentField(fields[currentFieldIndex + 1].key);
267
176
  return;
268
177
  }
269
- // Enter key on metadata field to enter metadata section
270
- if (currentField === "metadata" && key.return) {
271
- setInMetadataSection(true);
272
- setSelectedMetadataIndex(0); // Start at "add new" row
178
+ }, { isActive: !inMetadataSection });
179
+ // Metadata section input handler - active when in metadata section
180
+ useInput((input, key) => {
181
+ const metadataKeys = Object.keys(formData.metadata);
182
+ const maxIndex = metadataKeys.length + 1;
183
+ // Handle input mode (typing key or value)
184
+ if (metadataInputMode) {
185
+ if (metadataInputMode === "key" && key.return && metadataKey.trim()) {
186
+ setMetadataInputMode("value");
187
+ return;
188
+ }
189
+ else if (metadataInputMode === "value" && key.return) {
190
+ if (metadataKey.trim() && metadataValue.trim()) {
191
+ setFormData({
192
+ ...formData,
193
+ metadata: {
194
+ ...formData.metadata,
195
+ [metadataKey.trim()]: metadataValue.trim(),
196
+ },
197
+ });
198
+ }
199
+ setMetadataKey("");
200
+ setMetadataValue("");
201
+ setMetadataInputMode(null);
202
+ setSelectedMetadataIndex(0);
203
+ return;
204
+ }
205
+ else if (key.escape) {
206
+ setMetadataKey("");
207
+ setMetadataValue("");
208
+ setMetadataInputMode(null);
209
+ return;
210
+ }
211
+ else if (key.tab) {
212
+ setMetadataInputMode(metadataInputMode === "key" ? "value" : "key");
213
+ return;
214
+ }
273
215
  return;
274
216
  }
275
- });
217
+ // Navigation mode in metadata section
218
+ if (key.upArrow && selectedMetadataIndex > 0) {
219
+ setSelectedMetadataIndex(selectedMetadataIndex - 1);
220
+ }
221
+ else if (key.downArrow && selectedMetadataIndex < maxIndex) {
222
+ setSelectedMetadataIndex(selectedMetadataIndex + 1);
223
+ }
224
+ else if (key.return) {
225
+ if (selectedMetadataIndex === 0) {
226
+ setMetadataKey("");
227
+ setMetadataValue("");
228
+ setMetadataInputMode("key");
229
+ }
230
+ else if (selectedMetadataIndex === maxIndex) {
231
+ setInMetadataSection(false);
232
+ setSelectedMetadataIndex(0);
233
+ setMetadataKey("");
234
+ setMetadataValue("");
235
+ setMetadataInputMode(null);
236
+ }
237
+ else if (selectedMetadataIndex >= 1 &&
238
+ selectedMetadataIndex <= metadataKeys.length) {
239
+ const keyToEdit = metadataKeys[selectedMetadataIndex - 1];
240
+ setMetadataKey(keyToEdit || "");
241
+ setMetadataValue(formData.metadata[keyToEdit] || "");
242
+ const newMetadata = { ...formData.metadata };
243
+ delete newMetadata[keyToEdit];
244
+ setFormData({ ...formData, metadata: newMetadata });
245
+ setMetadataInputMode("key");
246
+ }
247
+ }
248
+ else if ((input === "d" || key.delete) &&
249
+ selectedMetadataIndex >= 1 &&
250
+ selectedMetadataIndex <= metadataKeys.length) {
251
+ const keyToDelete = metadataKeys[selectedMetadataIndex - 1];
252
+ const newMetadata = { ...formData.metadata };
253
+ delete newMetadata[keyToDelete];
254
+ setFormData({ ...formData, metadata: newMetadata });
255
+ const newLength = Object.keys(newMetadata).length;
256
+ if (selectedMetadataIndex > newLength) {
257
+ setSelectedMetadataIndex(Math.max(0, newLength));
258
+ }
259
+ }
260
+ else if (key.escape || input === "q") {
261
+ setInMetadataSection(false);
262
+ setSelectedMetadataIndex(0);
263
+ setMetadataKey("");
264
+ setMetadataValue("");
265
+ setMetadataInputMode(null);
266
+ }
267
+ }, { isActive: inMetadataSection });
276
268
  // Validate custom resource configuration
277
269
  const validateCustomResources = () => {
278
270
  if (formData.resource_size !== "CUSTOM_SIZE") {
@@ -383,7 +375,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
383
375
  return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: "[Enter to create]" }, field.key));
384
376
  }
385
377
  if (field.type === "text") {
386
- return (_jsx(FormTextInput, { label: field.label, value: String(fieldData || ""), onChange: (value) => setFormData({ ...formData, [field.key]: value }), isActive: isActive, placeholder: field.placeholder }, field.key));
378
+ return (_jsx(FormTextInput, { label: field.label, value: String(fieldData || ""), onChange: (value) => setFormData({ ...formData, [field.key]: value }), onSubmit: handleCreate, isActive: isActive, placeholder: field.placeholder }, field.key));
387
379
  }
388
380
  if (field.type === "select") {
389
381
  const value = fieldData;
@@ -76,6 +76,7 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
76
76
  }
77
77
  }, currentField === "allow_all");
78
78
  const handleDevboxNav = useFormSelectNavigation(formData.allow_devbox_to_devbox, BOOLEAN_OPTIONS, (value) => setFormData({ ...formData, allow_devbox_to_devbox: value }), currentField === "allow_devbox_to_devbox");
79
+ // Main form input handler - active when not in hostnames expanded mode
79
80
  useInput((input, key) => {
80
81
  // Handle result screen
81
82
  if (result) {
@@ -83,7 +84,9 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
83
84
  if (onCreate) {
84
85
  onCreate(result);
85
86
  }
86
- onBack();
87
+ else {
88
+ onBack();
89
+ }
87
90
  }
88
91
  return;
89
92
  }
@@ -103,10 +106,6 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
103
106
  if (submitting) {
104
107
  return;
105
108
  }
106
- // Handle hostnames expanded mode - let FormListManager handle input
107
- if (hostnamesExpanded) {
108
- return;
109
- }
110
109
  // Back to list
111
110
  if (input === "q" || key.escape) {
112
111
  onBack();
@@ -117,16 +116,16 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
117
116
  handleSubmit();
118
117
  return;
119
118
  }
120
- // Handle Enter on submit field
121
- if (currentField === "submit" && key.return) {
122
- handleSubmit();
123
- return;
124
- }
125
119
  // Handle Enter on hostnames field to expand
126
120
  if (currentField === "allowed_hostnames" && key.return) {
127
121
  setHostnamesExpanded(true);
128
122
  return;
129
123
  }
124
+ // Handle Enter on any field to submit (including text/select fields)
125
+ if (key.return) {
126
+ handleSubmit();
127
+ return;
128
+ }
130
129
  // Handle select field navigation
131
130
  if (handleAllowAllNav(input, key))
132
131
  return;
@@ -142,7 +141,7 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
142
141
  setCurrentField(fields[currentFieldIndex + 1].key);
143
142
  return;
144
143
  }
145
- });
144
+ }, { isActive: !hostnamesExpanded });
146
145
  const handleSubmit = async () => {
147
146
  // Validate required fields
148
147
  if (!formData.name.trim()) {
@@ -243,7 +242,7 @@ export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) =>
243
242
  if (field.key === "name" && validationError) {
244
243
  setValidationError(null);
245
244
  }
246
- }, isActive: isActive, placeholder: field.key === "name"
245
+ }, onSubmit: handleSubmit, isActive: isActive, placeholder: field.key === "name"
247
246
  ? "my-network-policy"
248
247
  : field.key === "description"
249
248
  ? "Policy description"
@@ -3,6 +3,6 @@ import { Text } from "ink";
3
3
  import TextInput from "ink-text-input";
4
4
  import { FormField } from "./FormField.js";
5
5
  import { colors } from "../../utils/theme.js";
6
- export const FormTextInput = ({ label, value, onChange, isActive, placeholder, error, }) => {
7
- return (_jsx(FormField, { label: label, isActive: isActive, error: error, children: isActive ? (_jsx(TextInput, { value: value, onChange: onChange, placeholder: placeholder })) : (_jsx(Text, { color: error ? colors.error : colors.text, children: value || "(empty)" })) }));
6
+ export const FormTextInput = ({ label, value, onChange, isActive, placeholder, error, onSubmit, }) => {
7
+ return (_jsx(FormField, { label: label, isActive: isActive, error: error, children: isActive ? (_jsx(TextInput, { value: value, onChange: onChange, placeholder: placeholder, onSubmit: onSubmit })) : (_jsx(Text, { color: error ? colors.error : colors.text, children: value || "(empty)" })) }));
8
8
  };
@@ -51,8 +51,8 @@ export function BlueprintDetailScreen({ blueprintId, }) {
51
51
  }, [blueprintId, loading, fetchedBlueprint]);
52
52
  // Use fetched blueprint for full details, fall back to store for basic display
53
53
  const blueprint = fetchedBlueprint || blueprintFromStore;
54
- // Show loading state while fetching
55
- if (loading && !blueprint) {
54
+ // Show loading state while fetching or before fetch starts
55
+ if (!blueprint && blueprintId && !error) {
56
56
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
57
57
  { label: "Blueprints" },
58
58
  { label: "Loading...", active: true },
@@ -183,6 +183,21 @@ export function BlueprintDetailScreen({ blueprintId, }) {
183
183
  });
184
184
  }
185
185
  }
186
+ // Error section - show failure reason if present
187
+ if (blueprint.failure_reason) {
188
+ detailSections.push({
189
+ title: "Error",
190
+ icon: figures.cross,
191
+ color: colors.error,
192
+ fields: [
193
+ {
194
+ label: "Failure Reason",
195
+ value: blueprint.failure_reason,
196
+ color: colors.error,
197
+ },
198
+ ],
199
+ });
200
+ }
186
201
  // Operations available for blueprints
187
202
  const operations = [
188
203
  {
@@ -262,6 +277,9 @@ export function BlueprintDetailScreen({ blueprintId, }) {
262
277
  lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", bp.id] }, "core-id"));
263
278
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Name: ", bp.name || "(none)"] }, "core-name"));
264
279
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Status: ", bp.status] }, "core-status"));
280
+ if (bp.failure_reason) {
281
+ lines.push(_jsxs(Text, { color: colors.error, children: [" ", "Failure Reason: ", bp.failure_reason] }, "core-failure"));
282
+ }
265
283
  if (bp.create_time_ms) {
266
284
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Created: ", new Date(bp.create_time_ms).toLocaleString()] }, "core-created"));
267
285
  }
@@ -40,16 +40,16 @@ export function DevboxDetailScreen({ devboxId }) {
40
40
  }, [devboxFromStore, devboxId, loading, fetchedDevbox, setDevboxesInStore]);
41
41
  // Use devbox from store or fetched devbox
42
42
  const devbox = devboxFromStore || fetchedDevbox;
43
- // Show loading state while fetching
44
- if (loading) {
43
+ // Show loading state while fetching or before fetch starts
44
+ if (!devbox && devboxId && !error) {
45
45
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Loading...", active: true }] }), _jsx(SpinnerComponent, { message: "Loading devbox details..." })] }));
46
46
  }
47
47
  // Show error state if fetch failed
48
48
  if (error) {
49
49
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Error", active: true }] }), _jsx(ErrorMessage, { message: "Failed to load devbox details", error: error })] }));
50
50
  }
51
- // Show error if no devbox found and not loading
52
- if (!devbox && !loading) {
51
+ // Show error if no devbox found
52
+ if (!devbox) {
53
53
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Not Found", active: true }] }), _jsx(ErrorMessage, { message: `Devbox ${devboxId || "unknown"} not found`, error: new Error("Devbox not found in cache and could not be fetched") })] }));
54
54
  }
55
55
  // At this point devbox is guaranteed to exist (loading check above handles the null case)
@@ -58,8 +58,8 @@ export function NetworkPolicyDetailScreen({ networkPolicyId, }) {
58
58
  }, [networkPolicyId, loading, fetchedPolicy]);
59
59
  // Use fetched policy for full details, fall back to store for basic display
60
60
  const policy = fetchedPolicy || policyFromStore;
61
- // Show loading state while fetching
62
- if (loading && !policy) {
61
+ // Show loading state while fetching or before fetch starts
62
+ if (!policy && networkPolicyId && !error) {
63
63
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
64
64
  { label: "Network Policies" },
65
65
  { label: "Loading...", active: true },
@@ -111,8 +111,8 @@ export function ObjectDetailScreen({ objectId }) {
111
111
  return;
112
112
  }
113
113
  }, { isActive: showDownloadPrompt || !!downloadResult || !!downloadError });
114
- // Show loading state while fetching
115
- if (loading && !storageObject) {
114
+ // Show loading state while fetching or before fetch starts
115
+ if (!storageObject && objectId && !error) {
116
116
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
117
117
  { label: "Storage Objects" },
118
118
  { label: "Loading...", active: true },
@@ -50,8 +50,8 @@ export function SnapshotDetailScreen({ snapshotId, }) {
50
50
  }, [snapshotId, loading, fetchedSnapshot]);
51
51
  // Use fetched snapshot for full details, fall back to store for basic display
52
52
  const snapshot = fetchedSnapshot || snapshotFromStore;
53
- // Show loading state while fetching
54
- if (loading && !snapshot) {
53
+ // Show loading state while fetching or before fetch starts
54
+ if (!snapshot && snapshotId && !error) {
55
55
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
56
56
  { label: "Snapshots" },
57
57
  { label: "Loading...", active: true },
@@ -312,6 +312,24 @@ export function createProgram() {
312
312
  const { pruneBlueprints } = await import("../commands/blueprint/prune.js");
313
313
  await pruneBlueprints(name, options);
314
314
  });
315
+ blueprint
316
+ .command("from-dockerfile")
317
+ .description("Create a blueprint from a Dockerfile with build context support")
318
+ .requiredOption("--name <name>", "Blueprint name (required)")
319
+ .option("--build-context <path>", "Build context directory (default: current directory)")
320
+ .option("--dockerfile <path>", "Dockerfile path (default: Dockerfile in build context)")
321
+ .option("--system-setup-commands <commands...>", "System setup commands")
322
+ .option("--resources <size>", "Resource size (X_SMALL, SMALL, MEDIUM, LARGE, X_LARGE, XX_LARGE)")
323
+ .option("--architecture <arch>", "Architecture (arm64, x86_64)")
324
+ .option("--available-ports <ports...>", "Available ports")
325
+ .option("--root", "Run as root")
326
+ .option("--user <user:uid>", "Run as this user (format: username:uid)")
327
+ .option("--ttl <seconds>", "TTL in seconds for the build context object (default: 3600)")
328
+ .option("-o, --output [format]", "Output format: text|json|yaml (default: json)")
329
+ .action(async (options) => {
330
+ const { createBlueprintFromDockerfile } = await import("../commands/blueprint/from-dockerfile.js");
331
+ await createBlueprintFromDockerfile(options);
332
+ });
315
333
  // Object storage commands
316
334
  const object = program
317
335
  .command("object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {