@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 +1 -0
- package/dist/commands/blueprint/from-dockerfile.js +182 -0
- package/dist/components/Banner.js +27 -5
- package/dist/components/DevboxCreatePage.js +103 -111
- package/dist/components/LogsViewer.js +140 -61
- package/dist/components/MainMenu.js +77 -22
- package/dist/components/NavigationTips.js +174 -4
- package/dist/components/NetworkPolicyCreatePage.js +11 -12
- package/dist/components/ResourceDetailPage.js +44 -2
- package/dist/components/form/FormTextInput.js +2 -2
- package/dist/screens/BlueprintDetailScreen.js +20 -2
- package/dist/screens/DevboxDetailScreen.js +4 -4
- package/dist/screens/NetworkPolicyDetailScreen.js +2 -2
- package/dist/screens/ObjectDetailScreen.js +2 -2
- package/dist/screens/SnapshotDetailScreen.js +2 -2
- package/dist/utils/commands.js +18 -0
- package/dist/utils/logFormatter.js +16 -17
- package/package.json +1 -1
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 {
|
|
92
|
+
const { stdout } = useStdout();
|
|
92
93
|
const timeoutRef = useRef(null);
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
250
|
-
if (currentField === "
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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;
|