@runloop/rl-cli 1.6.0 → 1.7.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.
@@ -17,6 +17,20 @@ function parseEnvVars(envVars) {
17
17
  }
18
18
  return result;
19
19
  }
20
+ // Parse secrets from ENV_VAR=SECRET_NAME format
21
+ function parseSecrets(secrets) {
22
+ const result = {};
23
+ for (const secret of secrets) {
24
+ const eqIndex = secret.indexOf("=");
25
+ if (eqIndex === -1) {
26
+ throw new Error(`Invalid secret format: ${secret}. Expected ENV_VAR=SECRET_NAME`);
27
+ }
28
+ const envVarName = secret.substring(0, eqIndex);
29
+ const secretName = secret.substring(eqIndex + 1);
30
+ result[envVarName] = secretName;
31
+ }
32
+ return result;
33
+ }
20
34
  // Parse code mounts from JSON format
21
35
  function parseCodeMounts(codeMounts) {
22
36
  return codeMounts.map((mount) => {
@@ -110,6 +124,10 @@ export async function createDevbox(options = {}) {
110
124
  if (options.codeMounts && options.codeMounts.length > 0) {
111
125
  createRequest.code_mounts = parseCodeMounts(options.codeMounts);
112
126
  }
127
+ // Handle secrets
128
+ if (options.secrets && options.secrets.length > 0) {
129
+ createRequest.secrets = parseSecrets(options.secrets);
130
+ }
113
131
  if (Object.keys(launchParameters).length > 0) {
114
132
  createRequest.launch_parameters = launchParameters;
115
133
  }
@@ -50,11 +50,36 @@ export async function createTunnel(devboxId, options) {
50
50
  `${localPort}:localhost:${remotePort}`,
51
51
  `${user}@${sshInfo.url}`,
52
52
  ];
53
+ const tunnelUrl = `http://localhost:${localPort}`;
53
54
  console.log(`Starting tunnel: local port ${localPort} -> remote port ${remotePort}`);
55
+ console.log(`Tunnel URL: ${tunnelUrl}`);
54
56
  console.log("Press Ctrl+C to stop the tunnel.");
55
57
  const tunnelProcess = spawn("/usr/bin/ssh", tunnelArgs, {
56
58
  stdio: "inherit",
57
59
  });
60
+ // Open browser if --open flag is set
61
+ if (options.open) {
62
+ // Small delay to let the tunnel establish
63
+ setTimeout(async () => {
64
+ const { exec } = await import("child_process");
65
+ const platform = process.platform;
66
+ let openCommand;
67
+ if (platform === "darwin") {
68
+ openCommand = `open "${tunnelUrl}"`;
69
+ }
70
+ else if (platform === "win32") {
71
+ openCommand = `start "${tunnelUrl}"`;
72
+ }
73
+ else {
74
+ openCommand = `xdg-open "${tunnelUrl}"`;
75
+ }
76
+ exec(openCommand, (error) => {
77
+ if (error) {
78
+ console.log(`\nCould not open browser: ${error.message}`);
79
+ }
80
+ });
81
+ }, 1000);
82
+ }
58
83
  tunnelProcess.on("close", (code) => {
59
84
  console.log("\nTunnel closed.");
60
85
  processUtils.exit(code || 0);
@@ -14,8 +14,8 @@ import { colors } from "../utils/theme.js";
14
14
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
15
15
  import { useNavigation } from "../store/navigationStore.js";
16
16
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
17
- import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
18
- import { LogsViewer } from "./LogsViewer.js";
17
+ import { suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
18
+ import { StreamingLogsViewer } from "./StreamingLogsViewer.js";
19
19
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
20
20
  { label: "Devboxes" },
21
21
  { label: devbox.name || devbox.id, active: true },
@@ -30,6 +30,17 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
30
30
  const [execScroll, setExecScroll] = React.useState(0);
31
31
  const [copyStatus, setCopyStatus] = React.useState(null);
32
32
  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
33
+ // Snapshot form state
34
+ const [snapshotFormMode, setSnapshotFormMode] = React.useState(false);
35
+ const [snapshotName, setSnapshotName] = React.useState("");
36
+ const [snapshotCommitMessage, setSnapshotCommitMessage] = React.useState("");
37
+ const [snapshotMetadata, setSnapshotMetadata] = React.useState({});
38
+ const [snapshotFormField, setSnapshotFormField] = React.useState("name");
39
+ const [inSnapshotMetadataSection, setInSnapshotMetadataSection] = React.useState(false);
40
+ const [snapshotMetadataKey, setSnapshotMetadataKey] = React.useState("");
41
+ const [snapshotMetadataValue, setSnapshotMetadataValue] = React.useState("");
42
+ const [snapshotMetadataInputMode, setSnapshotMetadataInputMode] = React.useState(null);
43
+ const [selectedSnapshotMetadataIndex, setSelectedSnapshotMetadataIndex] = React.useState(0);
33
44
  // Calculate viewport for exec output:
34
45
  // - Breadcrumb (3 lines + marginBottom): 4 lines
35
46
  // - Command header (border + 2 content + border + marginBottom): 5 lines
@@ -163,14 +174,193 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
163
174
  !showDeleteConfirm) {
164
175
  setShowDeleteConfirm(true);
165
176
  }
177
+ // Show snapshot form
178
+ if (executingOperation === "snapshot" &&
179
+ !loading &&
180
+ devbox &&
181
+ !snapshotFormMode &&
182
+ !operationResult &&
183
+ !operationError) {
184
+ setSnapshotFormMode(true);
185
+ setSnapshotFormField("name");
186
+ }
166
187
  }, [executingOperation]);
167
188
  // Handle Ctrl+C to exit
168
189
  useExitOnCtrlC();
169
190
  useInput((input, key) => {
170
- // Handle operation input mode
171
- if (executingOperation && !operationResult && !operationError) {
191
+ // Handle snapshot metadata section input
192
+ if (snapshotFormMode && inSnapshotMetadataSection) {
193
+ const metadataKeys = Object.keys(snapshotMetadata);
194
+ const maxIndex = metadataKeys.length + 1;
195
+ // Handle input mode (typing key or value)
196
+ if (snapshotMetadataInputMode) {
197
+ if (snapshotMetadataInputMode === "key" &&
198
+ key.return &&
199
+ snapshotMetadataKey.trim()) {
200
+ setSnapshotMetadataInputMode("value");
201
+ return;
202
+ }
203
+ else if (snapshotMetadataInputMode === "value" && key.return) {
204
+ if (snapshotMetadataKey.trim() && snapshotMetadataValue.trim()) {
205
+ setSnapshotMetadata({
206
+ ...snapshotMetadata,
207
+ [snapshotMetadataKey.trim()]: snapshotMetadataValue.trim(),
208
+ });
209
+ }
210
+ setSnapshotMetadataKey("");
211
+ setSnapshotMetadataValue("");
212
+ setSnapshotMetadataInputMode(null);
213
+ setSelectedSnapshotMetadataIndex(0);
214
+ return;
215
+ }
216
+ else if (key.escape) {
217
+ setSnapshotMetadataKey("");
218
+ setSnapshotMetadataValue("");
219
+ setSnapshotMetadataInputMode(null);
220
+ return;
221
+ }
222
+ else if (key.tab) {
223
+ setSnapshotMetadataInputMode(snapshotMetadataInputMode === "key" ? "value" : "key");
224
+ return;
225
+ }
226
+ return;
227
+ }
228
+ // Navigation mode in metadata section
229
+ if (key.upArrow && selectedSnapshotMetadataIndex > 0) {
230
+ setSelectedSnapshotMetadataIndex(selectedSnapshotMetadataIndex - 1);
231
+ }
232
+ else if (key.downArrow && selectedSnapshotMetadataIndex < maxIndex) {
233
+ setSelectedSnapshotMetadataIndex(selectedSnapshotMetadataIndex + 1);
234
+ }
235
+ else if (key.return) {
236
+ if (selectedSnapshotMetadataIndex === 0) {
237
+ setSnapshotMetadataKey("");
238
+ setSnapshotMetadataValue("");
239
+ setSnapshotMetadataInputMode("key");
240
+ }
241
+ else if (selectedSnapshotMetadataIndex === maxIndex) {
242
+ setInSnapshotMetadataSection(false);
243
+ setSelectedSnapshotMetadataIndex(0);
244
+ setSnapshotMetadataKey("");
245
+ setSnapshotMetadataValue("");
246
+ setSnapshotMetadataInputMode(null);
247
+ }
248
+ else if (selectedSnapshotMetadataIndex >= 1 &&
249
+ selectedSnapshotMetadataIndex <= metadataKeys.length) {
250
+ const keyToEdit = metadataKeys[selectedSnapshotMetadataIndex - 1];
251
+ setSnapshotMetadataKey(keyToEdit || "");
252
+ setSnapshotMetadataValue(snapshotMetadata[keyToEdit] || "");
253
+ const newMetadata = { ...snapshotMetadata };
254
+ delete newMetadata[keyToEdit];
255
+ setSnapshotMetadata(newMetadata);
256
+ setSnapshotMetadataInputMode("key");
257
+ }
258
+ }
259
+ else if ((input === "d" || key.delete) &&
260
+ selectedSnapshotMetadataIndex >= 1 &&
261
+ selectedSnapshotMetadataIndex <= metadataKeys.length) {
262
+ const keyToDelete = metadataKeys[selectedSnapshotMetadataIndex - 1];
263
+ const newMetadata = { ...snapshotMetadata };
264
+ delete newMetadata[keyToDelete];
265
+ setSnapshotMetadata(newMetadata);
266
+ const newLength = Object.keys(newMetadata).length;
267
+ if (selectedSnapshotMetadataIndex > newLength) {
268
+ setSelectedSnapshotMetadataIndex(Math.max(0, newLength));
269
+ }
270
+ }
271
+ else if (key.escape || input === "q") {
272
+ setInSnapshotMetadataSection(false);
273
+ setSelectedSnapshotMetadataIndex(0);
274
+ setSnapshotMetadataKey("");
275
+ setSnapshotMetadataValue("");
276
+ setSnapshotMetadataInputMode(null);
277
+ }
278
+ return;
279
+ }
280
+ // Handle snapshot form mode (main form navigation)
281
+ if (snapshotFormMode && !inSnapshotMetadataSection) {
282
+ const snapshotFields = [
283
+ "name",
284
+ "commit_message",
285
+ "metadata",
286
+ "create",
287
+ ];
288
+ const currentFieldIndex = snapshotFields.indexOf(snapshotFormField);
289
+ if (input === "q" || key.escape) {
290
+ // Cancel snapshot form
291
+ setSnapshotFormMode(false);
292
+ setSnapshotName("");
293
+ setSnapshotCommitMessage("");
294
+ setSnapshotMetadata({});
295
+ setSnapshotFormField("name");
296
+ setExecutingOperation(null);
297
+ if (skipOperationsMenu) {
298
+ onBack();
299
+ }
300
+ return;
301
+ }
302
+ // Navigate between fields (only when not actively editing text fields)
303
+ if (snapshotFormField !== "name" &&
304
+ snapshotFormField !== "commit_message") {
305
+ if (key.upArrow && currentFieldIndex > 0) {
306
+ setSnapshotFormField(snapshotFields[currentFieldIndex - 1]);
307
+ return;
308
+ }
309
+ if (key.downArrow && currentFieldIndex < snapshotFields.length - 1) {
310
+ setSnapshotFormField(snapshotFields[currentFieldIndex + 1]);
311
+ return;
312
+ }
313
+ }
314
+ // Handle Enter key
315
+ if (key.return) {
316
+ if (snapshotFormField === "name") {
317
+ // Move to commit_message field
318
+ setSnapshotFormField("commit_message");
319
+ }
320
+ else if (snapshotFormField === "commit_message") {
321
+ // Move to metadata field
322
+ setSnapshotFormField("metadata");
323
+ }
324
+ else if (snapshotFormField === "metadata") {
325
+ // Enter metadata section
326
+ setInSnapshotMetadataSection(true);
327
+ setSelectedSnapshotMetadataIndex(0);
328
+ }
329
+ else if (snapshotFormField === "create") {
330
+ // Execute snapshot creation
331
+ executeOperation();
332
+ }
333
+ return;
334
+ }
335
+ // Tab navigation (when not in text input fields)
336
+ if (key.tab &&
337
+ snapshotFormField !== "name" &&
338
+ snapshotFormField !== "commit_message") {
339
+ const nextIndex = key.shift
340
+ ? Math.max(0, currentFieldIndex - 1)
341
+ : Math.min(snapshotFields.length - 1, currentFieldIndex + 1);
342
+ setSnapshotFormField(snapshotFields[nextIndex]);
343
+ return;
344
+ }
345
+ return;
346
+ }
347
+ // Handle operation input mode (for exec, upload, tunnel)
348
+ if (executingOperation &&
349
+ !operationResult &&
350
+ !operationError &&
351
+ !snapshotFormMode) {
172
352
  if (key.return && operationInput.trim()) {
173
- executeOperation();
353
+ // For exec, navigate to dedicated exec screen
354
+ if (executingOperation === "exec") {
355
+ navigate("devbox-exec", {
356
+ devboxId: devbox.id,
357
+ devboxName: devbox.name || devbox.id,
358
+ execCommand: operationInput,
359
+ });
360
+ }
361
+ else {
362
+ executeOperation();
363
+ }
174
364
  }
175
365
  else if (input === "q" || key.escape) {
176
366
  setExecutingOperation(null);
@@ -196,6 +386,40 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
196
386
  setExecutingOperation(null);
197
387
  }
198
388
  }
389
+ else if (input === "o" &&
390
+ operationResult &&
391
+ typeof operationResult === "object" &&
392
+ operationResult.__customRender === "tunnel") {
393
+ // Open tunnel URL in browser
394
+ const tunnelUrl = operationResult.__tunnelUrl;
395
+ if (tunnelUrl) {
396
+ const openBrowser = async () => {
397
+ const { exec } = await import("child_process");
398
+ const platform = process.platform;
399
+ let openCommand;
400
+ if (platform === "darwin") {
401
+ openCommand = `open "${tunnelUrl}"`;
402
+ }
403
+ else if (platform === "win32") {
404
+ openCommand = `start "${tunnelUrl}"`;
405
+ }
406
+ else {
407
+ openCommand = `xdg-open "${tunnelUrl}"`;
408
+ }
409
+ exec(openCommand, (error) => {
410
+ if (error) {
411
+ setCopyStatus("Could not open browser");
412
+ setTimeout(() => setCopyStatus(null), 2000);
413
+ }
414
+ else {
415
+ setCopyStatus("Opened in browser!");
416
+ setTimeout(() => setCopyStatus(null), 2000);
417
+ }
418
+ });
419
+ };
420
+ openBrowser();
421
+ }
422
+ }
199
423
  else if ((key.upArrow || input === "k") &&
200
424
  operationResult &&
201
425
  typeof operationResult === "object" &&
@@ -238,6 +462,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
238
462
  setExecScroll(maxScroll);
239
463
  }
240
464
  else if (input === "c" &&
465
+ !key.ctrl && // Ignore if Ctrl+C for quit
241
466
  operationResult &&
242
467
  typeof operationResult === "object" &&
243
468
  operationResult.__customRender === "exec") {
@@ -316,19 +541,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
316
541
  try {
317
542
  setLoading(true);
318
543
  switch (executingOperation) {
319
- case "exec":
320
- // Use service layer (already truncates output to prevent Yoga crashes)
321
- const execResult = await execCommand(devbox.id, operationInput);
322
- // Format exec result for custom rendering
323
- const formattedExecResult = {
324
- __customRender: "exec",
325
- command: operationInput,
326
- stdout: execResult.stdout || "",
327
- stderr: execResult.stderr || "",
328
- exitCode: execResult.exit_code ?? 0,
329
- };
330
- setOperationResult(formattedExecResult);
331
- break;
544
+ // Note: "exec" is now handled by ExecViewer component directly
332
545
  case "upload":
333
546
  // Use service layer
334
547
  const filename = operationInput.split("/").pop() || "file";
@@ -336,9 +549,28 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
336
549
  setOperationResult(`File ${filename} uploaded successfully`);
337
550
  break;
338
551
  case "snapshot":
339
- // Use service layer
340
- const snapshot = await createDevboxSnapshot(devbox.id, operationInput || `snapshot-${Date.now()}`);
552
+ // Use service layer with form data
553
+ const snapshotOptions = {};
554
+ if (snapshotName.trim()) {
555
+ snapshotOptions.name = snapshotName.trim();
556
+ }
557
+ else {
558
+ snapshotOptions.name = `snapshot-${Date.now()}`;
559
+ }
560
+ if (snapshotCommitMessage.trim()) {
561
+ snapshotOptions.commit_message = snapshotCommitMessage.trim();
562
+ }
563
+ if (Object.keys(snapshotMetadata).length > 0) {
564
+ snapshotOptions.metadata = snapshotMetadata;
565
+ }
566
+ const snapshot = await createDevboxSnapshot(devbox.id, snapshotOptions);
341
567
  setOperationResult(`Snapshot created: ${snapshot.id}`);
568
+ // Reset snapshot form state
569
+ setSnapshotFormMode(false);
570
+ setSnapshotName("");
571
+ setSnapshotCommitMessage("");
572
+ setSnapshotMetadata({});
573
+ setSnapshotFormField("name");
342
574
  break;
343
575
  case "ssh":
344
576
  // Use service layer
@@ -372,19 +604,11 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
372
604
  });
373
605
  break;
374
606
  case "logs":
375
- // Use service layer (already truncates and escapes log messages)
376
- const logs = await getDevboxLogs(devbox.id);
377
- if (logs.length === 0) {
378
- setOperationResult("No logs available for this devbox.");
379
- }
380
- else {
381
- const logsResult = {
382
- __customRender: "logs",
383
- __logs: logs,
384
- __totalCount: logs.length,
385
- };
386
- setOperationResult(logsResult);
387
- }
607
+ // Set flag to show streaming logs viewer
608
+ const logsResult = {
609
+ __customRender: "logs",
610
+ };
611
+ setOperationResult(logsResult);
388
612
  break;
389
613
  case "tunnel":
390
614
  // Use service layer
@@ -394,10 +618,13 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
394
618
  }
395
619
  else {
396
620
  const tunnel = await createTunnel(devbox.id, port);
397
- setOperationResult(`Tunnel created!\n\n` +
398
- `Local Port: ${port}\n` +
399
- `Public URL: ${tunnel.url}\n\n` +
400
- `You can now access port ${port} on the devbox via:\n${tunnel.url}`);
621
+ // Store tunnel result with custom render type to enable "open in browser"
622
+ const tunnelResult = {
623
+ __customRender: "tunnel",
624
+ __tunnelUrl: tunnel.url,
625
+ __port: port,
626
+ };
627
+ setOperationResult(tunnelResult);
401
628
  }
402
629
  break;
403
630
  case "suspend":
@@ -477,16 +704,15 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
477
704
  { key: "Enter/q/esc", label: "Back" },
478
705
  ] })] }));
479
706
  }
480
- // Check for custom logs rendering
707
+ // Check for custom logs rendering - use streaming logs viewer
481
708
  if (operationResult &&
482
709
  typeof operationResult === "object" &&
483
710
  operationResult.__customRender === "logs") {
484
- const logs = operationResult.__logs || [];
485
- return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
711
+ return (_jsx(StreamingLogsViewer, { devboxId: devbox.id, breadcrumbItems: [
486
712
  ...breadcrumbItems,
487
713
  { label: "Logs", active: true },
488
714
  ], onBack: () => {
489
- // Clear large data structures immediately to prevent memory leaks
715
+ // Clear state
490
716
  setOperationResult(null);
491
717
  setOperationError(null);
492
718
  setOperationInput("");
@@ -498,15 +724,93 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
498
724
  else {
499
725
  setExecutingOperation(null);
500
726
  }
501
- }, title: "Logs" }));
727
+ } }));
728
+ }
729
+ // Check for custom tunnel rendering
730
+ if (operationResult &&
731
+ typeof operationResult === "object" &&
732
+ operationResult.__customRender === "tunnel") {
733
+ const tunnelUrl = operationResult.__tunnelUrl || "";
734
+ const tunnelPort = operationResult.__port || "";
735
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Open Tunnel", active: true }] }), _jsx(Header, { title: "Tunnel Created" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.success, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " Tunnel created successfully!"] }) }), _jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Port: " }), _jsx(Text, { color: colors.primary, bold: true, children: tunnelPort })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, children: "Public URL: " }) }), _jsx(Box, { children: _jsx(Text, { color: colors.info, bold: true, children: tunnelUrl }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["You can now access port ", tunnelPort, " on the devbox via this URL"] }) }), copyStatus && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) }))] }), _jsx(NavigationTips, { tips: [
736
+ { key: "o", label: "Open in Browser" },
737
+ { key: "Enter/q/esc", label: "Back" },
738
+ ] })] }));
502
739
  }
503
740
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Continue" }] })] }));
504
741
  }
742
+ // Snapshot form mode
743
+ if (snapshotFormMode && executingOperation === "snapshot" && devbox) {
744
+ if (loading) {
745
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
746
+ ...breadcrumbItems,
747
+ { label: "Create Snapshot", active: true },
748
+ ] }), _jsx(Header, { title: "Creating Snapshot" }), _jsx(SpinnerComponent, { message: "Creating snapshot..." })] }));
749
+ }
750
+ const snapshotFields = [
751
+ { key: "name", label: "Name (optional)" },
752
+ { key: "metadata", label: "Metadata (optional)" },
753
+ { key: "create", label: "Create Snapshot" },
754
+ ];
755
+ const currentFieldIndex = snapshotFields.findIndex((f) => f.key === snapshotFormField);
756
+ // Expanded metadata section
757
+ if (inSnapshotMetadataSection) {
758
+ const metadataKeys = Object.keys(snapshotMetadata);
759
+ const maxIndex = metadataKeys.length + 1;
760
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
761
+ ...breadcrumbItems,
762
+ { label: "Create Snapshot", active: true },
763
+ ] }), _jsx(Header, { title: "Create Snapshot - Metadata" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " Manage Metadata"] }), snapshotMetadataInputMode && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: selectedSnapshotMetadataIndex === 0
764
+ ? colors.success
765
+ : colors.warning, paddingX: 1, children: [_jsx(Text, { color: selectedSnapshotMetadataIndex === 0
766
+ ? colors.success
767
+ : colors.warning, bold: true, children: selectedSnapshotMetadataIndex === 0
768
+ ? "Adding New"
769
+ : "Editing" }), _jsx(Box, { children: snapshotMetadataInputMode === "key" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.primary, children: "Key: " }), _jsx(TextInput, { value: snapshotMetadataKey || "", onChange: setSnapshotMetadataKey, placeholder: "env" })] })) : (_jsxs(Text, { dimColor: true, children: ["Key: ", snapshotMetadataKey || ""] })) }), _jsx(Box, { children: snapshotMetadataInputMode === "value" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.primary, children: "Value: " }), _jsx(TextInput, { value: snapshotMetadataValue || "", onChange: setSnapshotMetadataValue, placeholder: "production" })] })) : (_jsxs(Text, { dimColor: true, children: ["Value: ", snapshotMetadataValue || ""] })) })] })), !snapshotMetadataInputMode && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: selectedSnapshotMetadataIndex === 0
770
+ ? colors.primary
771
+ : colors.textDim, children: [selectedSnapshotMetadataIndex === 0
772
+ ? figures.pointer
773
+ : " ", " "] }), _jsx(Text, { color: selectedSnapshotMetadataIndex === 0
774
+ ? colors.success
775
+ : colors.textDim, bold: selectedSnapshotMetadataIndex === 0, children: "+ Add new metadata" })] }), metadataKeys.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: metadataKeys.map((key, index) => {
776
+ const itemIndex = index + 1;
777
+ const isSelected = selectedSnapshotMetadataIndex === itemIndex;
778
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, bold: isSelected, children: [key, ": ", snapshotMetadata[key]] })] }, key));
779
+ }) })), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: selectedSnapshotMetadataIndex === maxIndex
780
+ ? colors.primary
781
+ : colors.textDim, children: [selectedSnapshotMetadataIndex === maxIndex
782
+ ? figures.pointer
783
+ : " ", " "] }), _jsxs(Text, { color: selectedSnapshotMetadataIndex === maxIndex
784
+ ? colors.success
785
+ : colors.textDim, bold: selectedSnapshotMetadataIndex === maxIndex, children: [figures.tick, " Done"] })] })] })), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: colors.border, paddingX: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: snapshotMetadataInputMode
786
+ ? `[Tab] Switch field • [Enter] ${snapshotMetadataInputMode === "key" ? "Next" : "Save"} • [esc] Cancel`
787
+ : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedSnapshotMetadataIndex === 0 ? "Add" : selectedSnapshotMetadataIndex === maxIndex ? "Done" : "Edit"} • [d] Delete • [esc] Back` }) })] })] }));
788
+ }
789
+ // Main snapshot form
790
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
791
+ ...breadcrumbItems,
792
+ { label: "Create Snapshot", active: true },
793
+ ] }), _jsx(Header, { title: "Create Snapshot" }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
794
+ const name = devbox.name || devbox.id;
795
+ return name.length > 100
796
+ ? name.substring(0, 100) + "..."
797
+ : name;
798
+ })() }) }), _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: snapshotFormField === "name" ? colors.primary : colors.textDim, children: [snapshotFormField === "name" ? figures.pointer : " ", " Name:", " "] }), snapshotFormField === "name" ? (_jsx(TextInput, { value: snapshotName, onChange: setSnapshotName, placeholder: "my-snapshot (optional)" })) : (_jsx(Text, { color: colors.text, children: snapshotName || "(auto-generated)" }))] }), _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: snapshotFormField === "commit_message"
799
+ ? colors.primary
800
+ : colors.textDim, children: [snapshotFormField === "commit_message" ? figures.pointer : " ", " ", "Commit Message:", " "] }), snapshotFormField === "commit_message" ? (_jsx(TextInput, { value: snapshotCommitMessage, onChange: setSnapshotCommitMessage, placeholder: "Describe this snapshot (optional)" })) : (_jsx(Text, { color: colors.text, children: snapshotCommitMessage || "(none)" }))] }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: snapshotFormField === "metadata"
801
+ ? colors.primary
802
+ : colors.textDim, children: [snapshotFormField === "metadata" ? figures.pointer : " ", " ", "Metadata:", " "] }), _jsxs(Text, { color: colors.text, children: [Object.keys(snapshotMetadata).length, " item(s)"] }), snapshotFormField === "metadata" && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to manage]"] }))] }), Object.keys(snapshotMetadata).length > 0 && (_jsx(Box, { marginLeft: 4, flexDirection: "column", children: Object.entries(snapshotMetadata).map(([key, value]) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [key, ": ", value] }, key))) }))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: snapshotFormField === "create" ? colors.success : colors.textDim, bold: snapshotFormField === "create", children: [snapshotFormField === "create" ? figures.pointer : " ", " ", figures.play, " Create Snapshot"] }) })] }), _jsx(NavigationTips, { showArrows: true, tips: [
803
+ {
804
+ key: "Enter",
805
+ label: snapshotFormField === "create" ? "Create" : "Select",
806
+ },
807
+ { key: "q/esc", label: "Cancel" },
808
+ ] })] }));
809
+ }
505
810
  // Operation input mode
506
811
  if (executingOperation && devbox) {
507
812
  const needsInput = executingOperation === "exec" ||
508
813
  executingOperation === "upload" ||
509
- executingOperation === "snapshot" ||
510
814
  executingOperation === "tunnel";
511
815
  if (loading) {
512
816
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
@@ -530,7 +834,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
530
834
  const prompts = {
531
835
  exec: "Command to execute:",
532
836
  upload: "File path to upload:",
533
- snapshot: "Snapshot name (optional):",
534
837
  tunnel: "Port number to expose:",
535
838
  };
536
839
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
@@ -542,12 +845,15 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
542
845
  ? "ls -la"
543
846
  : executingOperation === "upload"
544
847
  ? "/path/to/file"
545
- : executingOperation === "tunnel"
546
- ? "8080"
547
- : "my-snapshot" }) }), _jsx(NavigationTips, { tips: [
548
- { key: "Enter", label: "Execute" },
549
- { key: "q/esc", label: "Cancel" },
550
- ] })] })] }));
848
+ : "8080" }) }), _jsx(NavigationTips, { tips: executingOperation === "exec"
849
+ ? [
850
+ { key: "Enter", label: "Execute" },
851
+ { key: "q/esc", label: "Cancel" },
852
+ ]
853
+ : [
854
+ { key: "Enter", label: "Execute" },
855
+ { key: "q/esc", label: "Cancel" },
856
+ ] })] })] }));
551
857
  }
552
858
  // Operations selection mode - only show if not skipping
553
859
  if (!skipOperationsMenu || !executingOperation) {