@runloop/rl-cli 1.8.0 → 1.10.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.
Files changed (66) hide show
  1. package/README.md +21 -7
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/blueprint/delete.js +21 -0
  4. package/dist/commands/blueprint/list.js +226 -174
  5. package/dist/commands/blueprint/prune.js +13 -28
  6. package/dist/commands/devbox/create.js +41 -0
  7. package/dist/commands/devbox/list.js +142 -110
  8. package/dist/commands/devbox/rsync.js +69 -41
  9. package/dist/commands/devbox/scp.js +180 -39
  10. package/dist/commands/devbox/tunnel.js +4 -19
  11. package/dist/commands/gateway-config/create.js +53 -0
  12. package/dist/commands/gateway-config/delete.js +21 -0
  13. package/dist/commands/gateway-config/get.js +18 -0
  14. package/dist/commands/gateway-config/list.js +493 -0
  15. package/dist/commands/gateway-config/update.js +70 -0
  16. package/dist/commands/snapshot/list.js +11 -2
  17. package/dist/commands/snapshot/prune.js +265 -0
  18. package/dist/components/BenchmarkMenu.js +23 -3
  19. package/dist/components/DetailedInfoView.js +20 -0
  20. package/dist/components/DevboxActionsMenu.js +26 -62
  21. package/dist/components/DevboxCreatePage.js +763 -15
  22. package/dist/components/DevboxDetailPage.js +73 -24
  23. package/dist/components/GatewayConfigCreatePage.js +272 -0
  24. package/dist/components/LogsViewer.js +6 -40
  25. package/dist/components/ResourceDetailPage.js +143 -160
  26. package/dist/components/ResourceListView.js +3 -33
  27. package/dist/components/ResourcePicker.js +234 -0
  28. package/dist/components/SecretCreatePage.js +71 -27
  29. package/dist/components/SettingsMenu.js +12 -2
  30. package/dist/components/StateHistory.js +1 -20
  31. package/dist/components/StatusBadge.js +9 -2
  32. package/dist/components/StreamingLogsViewer.js +8 -42
  33. package/dist/components/form/FormTextInput.js +4 -2
  34. package/dist/components/resourceDetailTypes.js +18 -0
  35. package/dist/hooks/useInputHandler.js +103 -0
  36. package/dist/router/Router.js +79 -2
  37. package/dist/screens/BenchmarkDetailScreen.js +163 -0
  38. package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
  39. package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
  40. package/dist/screens/BenchmarkJobListScreen.js +479 -0
  41. package/dist/screens/BenchmarkListScreen.js +266 -0
  42. package/dist/screens/BenchmarkMenuScreen.js +6 -0
  43. package/dist/screens/BenchmarkRunDetailScreen.js +258 -22
  44. package/dist/screens/BenchmarkRunListScreen.js +21 -1
  45. package/dist/screens/BlueprintDetailScreen.js +5 -1
  46. package/dist/screens/DevboxCreateScreen.js +2 -2
  47. package/dist/screens/GatewayConfigDetailScreen.js +236 -0
  48. package/dist/screens/GatewayConfigListScreen.js +7 -0
  49. package/dist/screens/ScenarioRunDetailScreen.js +6 -0
  50. package/dist/screens/SecretDetailScreen.js +26 -2
  51. package/dist/screens/SettingsMenuScreen.js +3 -0
  52. package/dist/screens/SnapshotDetailScreen.js +6 -0
  53. package/dist/services/agentService.js +42 -0
  54. package/dist/services/benchmarkJobService.js +122 -0
  55. package/dist/services/benchmarkService.js +47 -0
  56. package/dist/services/gatewayConfigService.js +153 -0
  57. package/dist/services/scenarioService.js +34 -0
  58. package/dist/store/benchmarkJobStore.js +66 -0
  59. package/dist/store/benchmarkStore.js +63 -0
  60. package/dist/store/gatewayConfigStore.js +83 -0
  61. package/dist/utils/browser.js +22 -0
  62. package/dist/utils/clipboard.js +41 -0
  63. package/dist/utils/commands.js +105 -9
  64. package/dist/utils/gatewayConfigValidation.js +58 -0
  65. package/dist/utils/time.js +121 -0
  66. package/package.json +43 -43
@@ -42,6 +42,33 @@ function parseCodeMounts(codeMounts) {
42
42
  }
43
43
  });
44
44
  }
45
+ // Parse gateways from ENV_PREFIX=gateway,secret format
46
+ function parseGateways(gateways) {
47
+ const result = {};
48
+ for (const gateway of gateways) {
49
+ const eqIndex = gateway.indexOf("=");
50
+ if (eqIndex === -1) {
51
+ throw new Error(`Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`);
52
+ }
53
+ const envPrefix = gateway.substring(0, eqIndex);
54
+ const valueStr = gateway.substring(eqIndex + 1);
55
+ // Split by comma to get gateway and secret
56
+ const commaIndex = valueStr.indexOf(",");
57
+ if (commaIndex === -1) {
58
+ throw new Error(`Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`);
59
+ }
60
+ const gatewayIdOrName = valueStr.substring(0, commaIndex);
61
+ const secretIdOrName = valueStr.substring(commaIndex + 1);
62
+ if (!envPrefix || !gatewayIdOrName || !secretIdOrName) {
63
+ throw new Error(`Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`);
64
+ }
65
+ result[envPrefix] = {
66
+ gateway: gatewayIdOrName,
67
+ secret: secretIdOrName,
68
+ };
69
+ }
70
+ return result;
71
+ }
45
72
  export async function createDevbox(options = {}) {
46
73
  try {
47
74
  const client = getClient();
@@ -65,6 +92,10 @@ export async function createDevbox(options = {}) {
65
92
  (!options.idleTime && options.idleAction)) {
66
93
  outputError("Both --idle-time and --idle-action must be specified together");
67
94
  }
95
+ // Validate tunnel option
96
+ if (options.tunnel && !["open", "authenticated"].includes(options.tunnel)) {
97
+ outputError("Invalid tunnel mode. Must be either 'open' or 'authenticated'");
98
+ }
68
99
  // Build launch parameters
69
100
  const launchParameters = {};
70
101
  if (options.resources) {
@@ -128,6 +159,16 @@ export async function createDevbox(options = {}) {
128
159
  if (options.secrets && options.secrets.length > 0) {
129
160
  createRequest.secrets = parseSecrets(options.secrets);
130
161
  }
162
+ // Handle tunnel configuration
163
+ if (options.tunnel) {
164
+ createRequest.tunnel = {
165
+ auth_mode: options.tunnel,
166
+ };
167
+ }
168
+ // Handle gateways
169
+ if (options.gateways && options.gateways.length > 0) {
170
+ createRequest.gateways = parseGateways(options.gateways);
171
+ }
131
172
  if (Object.keys(launchParameters).length > 0) {
132
173
  createRequest.launch_parameters = launchParameters;
133
174
  }
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Text, useInput, useApp } from "ink";
3
+ import { Box, Text, useApp } from "ink";
4
4
  import figures from "figures";
5
5
  import { getClient } from "../../utils/client.js";
6
6
  import { SpinnerComponent } from "../../components/Spinner.js";
@@ -21,6 +21,8 @@ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
21
21
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
22
22
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
23
23
  import { useListSearch } from "../../hooks/useListSearch.js";
24
+ import { openInBrowser } from "../../utils/browser.js";
25
+ import { useInputHandler, } from "../../hooks/useInputHandler.js";
24
26
  import { colors } from "../../utils/theme.js";
25
27
  import { useDevboxStore } from "../../store/devboxStore.js";
26
28
  const DEFAULT_PAGE_SIZE = 10;
@@ -302,8 +304,10 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
302
304
  const startIndex = currentPage * PAGE_SIZE;
303
305
  const endIndex = startIndex + devboxes.length;
304
306
  // Filter operations based on devbox status
307
+ const hasTunnel = !!(selectedDevbox?.tunnel && selectedDevbox.tunnel.tunnel_key);
305
308
  const operations = selectedDevbox
306
- ? allOperations.filter((op) => {
309
+ ? allOperations
310
+ .filter((op) => {
307
311
  const devboxStatus = selectedDevbox.status;
308
312
  if (devboxStatus === "suspended") {
309
313
  return op.key === "resume" || op.key === "logs";
@@ -318,127 +322,155 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
318
322
  }
319
323
  return op.key === "logs" || op.key === "delete";
320
324
  })
321
- : allOperations;
322
- useInput((input, key) => {
323
- const pageDevboxes = devboxes.length;
324
- // Skip input handling when in search mode - let TextInput handle it
325
- if (search.searchMode) {
326
- if (key.escape) {
327
- search.cancelSearch();
325
+ .map((op) => {
326
+ // Dynamic tunnel label based on whether tunnel is active
327
+ if (op.key === "tunnel") {
328
+ return hasTunnel
329
+ ? {
330
+ ...op,
331
+ label: "Tunnel (Active)",
332
+ color: colors.success,
333
+ icon: figures.tick,
334
+ }
335
+ : op;
328
336
  }
337
+ return op;
338
+ })
339
+ : allOperations;
340
+ const closePopup = React.useCallback(() => {
341
+ setShowPopup(false);
342
+ setSelectedOperation(0);
343
+ }, []);
344
+ const handleListEscape = React.useCallback(() => {
345
+ if (search.handleEscape())
329
346
  return;
347
+ if (onBack) {
348
+ onBack();
330
349
  }
331
- // Skip input handling when in details view
332
- if (showDetails) {
333
- return;
350
+ else if (onExit) {
351
+ onExit();
334
352
  }
335
- // Skip input handling when in create view
336
- if (showCreate) {
337
- return;
353
+ else {
354
+ inkExit();
338
355
  }
339
- // Skip input handling when in actions view
340
- if (showActions) {
356
+ }, [search, onBack, onExit, inkExit]);
357
+ const handleOpenInBrowser = React.useCallback(() => {
358
+ if (!selectedDevbox)
341
359
  return;
342
- }
343
- // Handle popup navigation
344
- if (showPopup) {
345
- if (key.escape || input === "q") {
346
- setShowPopup(false);
347
- setSelectedOperation(0);
348
- }
349
- else if (key.upArrow && selectedOperation > 0) {
350
- setSelectedOperation(selectedOperation - 1);
351
- }
352
- else if (key.downArrow && selectedOperation < operations.length - 1) {
353
- setSelectedOperation(selectedOperation + 1);
354
- }
355
- else if (key.return) {
356
- setShowPopup(false);
357
- setShowActions(true);
358
- }
359
- else if (input) {
360
- const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
361
- if (matchedOpIndex !== -1) {
362
- setSelectedOperation(matchedOpIndex);
363
- setShowPopup(false);
364
- setShowActions(true);
365
- }
366
- }
367
- return;
368
- }
369
- // Handle list view
370
- if (key.upArrow && selectedIndex > 0) {
371
- setSelectedIndex(selectedIndex - 1);
372
- }
373
- else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
374
- setSelectedIndex(selectedIndex + 1);
375
- }
376
- else if ((input === "n" || key.rightArrow) &&
377
- !loading &&
378
- !navigating &&
379
- hasMore) {
360
+ openInBrowser(getDevboxUrl(selectedDevbox.id));
361
+ }, [selectedDevbox]);
362
+ const goToNextPage = React.useCallback(() => {
363
+ if (!loading && !navigating && hasMore) {
380
364
  nextPage();
381
365
  setSelectedIndex(0);
382
366
  }
383
- else if ((input === "p" || key.leftArrow) &&
384
- !loading &&
385
- !navigating &&
386
- hasPrev) {
367
+ }, [loading, navigating, hasMore, nextPage]);
368
+ const goToPrevPage = React.useCallback(() => {
369
+ if (!loading && !navigating && hasPrev) {
387
370
  prevPage();
388
371
  setSelectedIndex(0);
389
372
  }
390
- else if (key.return) {
391
- if (onNavigateToDetail && selectedDevbox) {
392
- onNavigateToDetail(selectedDevbox.id);
393
- }
394
- else {
395
- setShowDetails(true);
396
- }
397
- }
398
- else if (input === "a") {
399
- setShowPopup(true);
400
- setSelectedOperation(0);
401
- }
402
- else if (input === "c") {
403
- setShowCreate(true);
404
- }
405
- else if (input === "o" && selectedDevbox) {
406
- const url = getDevboxUrl(selectedDevbox.id);
407
- const openBrowser = async () => {
408
- const { exec } = await import("child_process");
409
- const platform = process.platform;
410
- let openCommand;
411
- if (platform === "darwin") {
412
- openCommand = `open "${url}"`;
413
- }
414
- else if (platform === "win32") {
415
- openCommand = `start "${url}"`;
416
- }
417
- else {
418
- openCommand = `xdg-open "${url}"`;
373
+ }, [loading, navigating, hasPrev, prevPage]);
374
+ const inputModes = React.useMemo(() => [
375
+ // Search mode: only escape to cancel, swallow everything else
376
+ {
377
+ name: "search",
378
+ active: () => search.searchMode,
379
+ bindings: {
380
+ escape: () => search.cancelSearch(),
381
+ },
382
+ captureAll: true,
383
+ },
384
+ // Subview guards: swallow all input when a child view is active
385
+ {
386
+ name: "subviews",
387
+ active: () => showDetails || showCreate || showActions,
388
+ bindings: {},
389
+ captureAll: true,
390
+ },
391
+ // Popup navigation
392
+ {
393
+ name: "popup",
394
+ active: () => showPopup,
395
+ bindings: {
396
+ escape: closePopup,
397
+ q: closePopup,
398
+ up: () => {
399
+ if (selectedOperation > 0)
400
+ setSelectedOperation(selectedOperation - 1);
401
+ },
402
+ down: () => {
403
+ if (selectedOperation < operations.length - 1)
404
+ setSelectedOperation(selectedOperation + 1);
405
+ },
406
+ enter: () => {
407
+ setShowPopup(false);
408
+ setShowActions(true);
409
+ },
410
+ },
411
+ onUnmatched: (input) => {
412
+ const idx = operations.findIndex((op) => op.shortcut === input);
413
+ if (idx !== -1) {
414
+ setSelectedOperation(idx);
415
+ setShowPopup(false);
416
+ setShowActions(true);
419
417
  }
420
- exec(openCommand);
421
- };
422
- openBrowser();
423
- }
424
- else if (input === "/") {
425
- search.enterSearchMode();
426
- }
427
- else if (key.escape) {
428
- if (search.handleEscape()) {
429
- return;
430
- }
431
- if (onBack) {
432
- onBack();
433
- }
434
- else if (onExit) {
435
- onExit();
436
- }
437
- else {
438
- inkExit();
439
- }
440
- }
441
- });
418
+ },
419
+ },
420
+ // List navigation (default mode)
421
+ {
422
+ name: "list",
423
+ active: () => true,
424
+ bindings: {
425
+ up: () => {
426
+ if (selectedIndex > 0)
427
+ setSelectedIndex(selectedIndex - 1);
428
+ },
429
+ down: () => {
430
+ if (selectedIndex < devboxes.length - 1)
431
+ setSelectedIndex(selectedIndex + 1);
432
+ },
433
+ n: goToNextPage,
434
+ right: goToNextPage,
435
+ p: goToPrevPage,
436
+ left: goToPrevPage,
437
+ enter: () => {
438
+ if (onNavigateToDetail && selectedDevbox) {
439
+ onNavigateToDetail(selectedDevbox.id);
440
+ }
441
+ else {
442
+ setShowDetails(true);
443
+ }
444
+ },
445
+ a: () => {
446
+ setShowPopup(true);
447
+ setSelectedOperation(0);
448
+ },
449
+ c: () => setShowCreate(true),
450
+ o: handleOpenInBrowser,
451
+ "/": () => search.enterSearchMode(),
452
+ escape: handleListEscape,
453
+ },
454
+ },
455
+ ], [
456
+ search,
457
+ showDetails,
458
+ showCreate,
459
+ showActions,
460
+ showPopup,
461
+ closePopup,
462
+ selectedOperation,
463
+ operations,
464
+ selectedIndex,
465
+ devboxes.length,
466
+ goToNextPage,
467
+ goToPrevPage,
468
+ onNavigateToDetail,
469
+ selectedDevbox,
470
+ handleOpenInBrowser,
471
+ handleListEscape,
472
+ ]);
473
+ useInputHandler(inputModes);
442
474
  // Create view
443
475
  if (showCreate) {
444
476
  return (_jsx(DevboxCreatePage, { onBack: () => {
@@ -1,62 +1,90 @@
1
1
  /**
2
2
  * Rsync files to/from devbox command
3
+ *
4
+ * Supports standard rsync-like syntax where the devbox ID (dbx_*) is used as a hostname:
5
+ * rli devbox rsync dbx_abc123:/remote/path ./local/path # download
6
+ * rli devbox rsync ./local/path dbx_abc123:/remote/path # upload
7
+ * rli devbox rsync root@dbx_abc123:/remote/path ./local/path # explicit user
8
+ *
9
+ * If no user is specified for a remote path, the devbox's configured user is used.
10
+ * Paths without a dbx_ hostname are treated as local paths.
3
11
  */
4
- import { exec } from "child_process";
12
+ import { execFile } from "child_process";
5
13
  import { promisify } from "util";
6
- import { getClient } from "../../utils/client.js";
7
14
  import { output, outputError } from "../../utils/output.js";
8
- import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
9
- const execAsync = promisify(exec);
10
- export async function rsyncFiles(devboxId, options) {
15
+ import { getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
16
+ import { parseSCPPath, resolveRemote } from "./scp.js";
17
+ const execFileAsync = promisify(execFile);
18
+ /**
19
+ * Build the rsync command for a single-remote transfer (local <-> devbox).
20
+ */
21
+ export function buildRsyncCommand(opts) {
22
+ // Rsync re-splits the -e value on whitespace internally, so the
23
+ // ProxyCommand (which contains spaces) must be single-quoted.
24
+ const sshTransport = `ssh -i ${opts.sshInfo.keyfilePath} -o 'ProxyCommand=${opts.proxyCommand}' -o StrictHostKeyChecking=no`;
25
+ const rsyncCommand = [
26
+ "rsync",
27
+ "-vrz", // v: verbose, r: recursive, z: compress
28
+ "-e",
29
+ sshTransport,
30
+ ];
31
+ if (opts.rsyncOptions) {
32
+ rsyncCommand.push(...opts.rsyncOptions.split(" "));
33
+ }
34
+ // Build src argument
35
+ if (opts.parsedSrc.isRemote) {
36
+ const user = opts.parsedSrc.user || opts.defaultUser;
37
+ rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`);
38
+ }
39
+ else {
40
+ rsyncCommand.push(opts.parsedSrc.path);
41
+ }
42
+ // Build dst argument
43
+ if (opts.parsedDst.isRemote) {
44
+ const user = opts.parsedDst.user || opts.defaultUser;
45
+ rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`);
46
+ }
47
+ else {
48
+ rsyncCommand.push(opts.parsedDst.path);
49
+ }
50
+ return rsyncCommand;
51
+ }
52
+ export async function rsyncFiles(src, dst, options) {
11
53
  try {
12
54
  // Check if SSH tools are available
13
55
  const sshToolsAvailable = await checkSSHTools();
14
56
  if (!sshToolsAvailable) {
15
57
  outputError("SSH tools (ssh, rsync, openssl) are not available on this system");
16
58
  }
17
- const client = getClient();
18
- // Get devbox details to determine user
19
- const devbox = await client.devboxes.retrieve(devboxId);
20
- const user = devbox.launch_parameters?.user_parameters?.username || "user";
21
- // Get SSH key
22
- const sshInfo = await getSSHKey(devboxId);
23
- if (!sshInfo) {
24
- outputError("Failed to create SSH key");
25
- }
26
- const proxyCommand = getProxyCommand();
27
- const sshOptions = `-i ${sshInfo.keyfilePath} -o ProxyCommand='${proxyCommand}' -o StrictHostKeyChecking=no`;
28
- const rsyncCommand = [
29
- "rsync",
30
- "-vrz", // v: verbose, r: recursive, z: compress
31
- "-e",
32
- `"ssh ${sshOptions}"`,
33
- ];
34
- if (options.rsyncOptions) {
35
- rsyncCommand.push(...options.rsyncOptions.split(" "));
36
- }
37
- // Handle remote paths (starting with :)
38
- if (options.src.startsWith(":")) {
39
- rsyncCommand.push(`${user}@${sshInfo.url}:${options.src.slice(1)}`);
40
- rsyncCommand.push(options.dst);
59
+ const parsedSrc = parseSCPPath(src);
60
+ const parsedDst = parseSCPPath(dst);
61
+ if (!parsedSrc.isRemote && !parsedDst.isRemote) {
62
+ outputError("At least one of src or dst must be a remote devbox path (e.g. dbx_<id>:/path)");
41
63
  }
42
- else {
43
- rsyncCommand.push(options.src);
44
- if (options.dst.startsWith(":")) {
45
- rsyncCommand.push(`${user}@${sshInfo.url}:${options.dst.slice(1)}`);
46
- }
47
- else {
48
- rsyncCommand.push(options.dst);
49
- }
64
+ if (parsedSrc.isRemote && parsedDst.isRemote) {
65
+ outputError("Devbox-to-devbox rsync is not supported. Only one side can be a remote devbox path.");
50
66
  }
51
- await execAsync(rsyncCommand.join(" "));
67
+ const devboxId = parsedSrc.isRemote ? parsedSrc.host : parsedDst.host;
68
+ const remote = await resolveRemote(devboxId);
69
+ const proxyCommand = getProxyCommand();
70
+ const rsyncCommand = buildRsyncCommand({
71
+ sshInfo: remote.sshInfo,
72
+ proxyCommand,
73
+ parsedSrc,
74
+ parsedDst,
75
+ defaultUser: remote.defaultUser,
76
+ rsyncOptions: options.rsyncOptions,
77
+ });
78
+ const [cmd, ...args] = rsyncCommand;
79
+ await execFileAsync(cmd, args);
52
80
  // Default: just output the destination for easy scripting
53
81
  if (!options.output || options.output === "text") {
54
- console.log(options.dst);
82
+ console.log(dst);
55
83
  }
56
84
  else {
57
85
  output({
58
- source: options.src,
59
- destination: options.dst,
86
+ source: src,
87
+ destination: dst,
60
88
  }, { format: options.output, defaultFormat: "json" });
61
89
  }
62
90
  }