@runloop/rl-cli 1.3.0 → 1.4.1

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
+ }
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useState, useEffect, useRef } from "react";
3
- import { Box, Text } from "ink";
3
+ import { Box, Text, useStdout } from "ink";
4
4
  import BigText from "ink-big-text";
5
5
  import Gradient from "ink-gradient";
6
6
  import { isLightMode } from "../utils/theme.js";
7
- import { useViewportHeight } from "../hooks/useViewportHeight.js";
8
7
  // Dramatic shades of green shimmer - wide range
9
8
  const DARK_SHIMMER_COLORS = [
10
9
  "#024A38", // Very very dark emerald
@@ -83,15 +82,38 @@ const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2
83
82
  const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0));
84
83
  // Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai")
85
84
  const MIN_WIDTH_FOR_BIG_BANNER = 90;
85
+ // Minimum height to show the full BigText banner - require generous room (40 lines)
86
+ const MIN_HEIGHT_FOR_BIG_BANNER = 40;
86
87
  // Animation interval in ms
87
88
  const SHIMMER_INTERVAL = 400;
88
89
  export const Banner = React.memo(() => {
89
90
  const [frameIndex, setFrameIndex] = useState(0);
90
91
  const frames = isLightMode() ? LIGHT_FRAMES : DARK_FRAMES;
91
- const { terminalWidth } = useViewportHeight();
92
+ const { stdout } = useStdout();
92
93
  const timeoutRef = useRef(null);
93
- // Determine if we should show compact mode
94
- const isCompact = terminalWidth < MIN_WIDTH_FOR_BIG_BANNER;
94
+ // Get raw terminal dimensions, responding to resize events
95
+ // Default to conservative values if we can't detect (triggers compact mode)
96
+ const getDimensions = React.useCallback(() => ({
97
+ width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
98
+ height: stdout?.rows && stdout.rows > 0 ? stdout.rows : 20,
99
+ }), [stdout]);
100
+ const [dimensions, setDimensions] = useState(getDimensions);
101
+ useEffect(() => {
102
+ // Update immediately on mount and when stdout changes
103
+ setDimensions(getDimensions());
104
+ if (!stdout)
105
+ return;
106
+ const handleResize = () => {
107
+ setDimensions(getDimensions());
108
+ };
109
+ stdout.on("resize", handleResize);
110
+ return () => {
111
+ stdout.off("resize", handleResize);
112
+ };
113
+ }, [stdout, getDimensions]);
114
+ // Determine if we should show compact mode (not enough width OR height)
115
+ const isCompact = dimensions.width < MIN_WIDTH_FOR_BIG_BANNER ||
116
+ dimensions.height < MIN_HEIGHT_FOR_BIG_BANNER;
95
117
  useEffect(() => {
96
118
  const tick = () => {
97
119
  setFrameIndex((prev) => (prev + 1) % frames.length);
@@ -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;