@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
@@ -1,64 +1,205 @@
1
1
  /**
2
2
  * SCP files to/from devbox command
3
+ *
4
+ * Supports standard SCP-like syntax where the devbox ID (dbx_*) is used as a hostname:
5
+ * rli devbox scp dbx_abc123:/remote/path ./local/path # download
6
+ * rli devbox scp ./local/path dbx_abc123:/remote/path # upload
7
+ * rli devbox scp root@dbx_abc123:/remote/path ./local/path # explicit user
8
+ * rli devbox scp dbx_src:/file dbx_dst:/file # devbox-to-devbox
9
+ *
10
+ * If no user is specified for a remote path, the devbox's configured user is used.
11
+ * Paths without a dbx_ hostname are treated as local paths.
12
+ *
13
+ * Devbox-to-devbox transfers use scp -3 to route data through the local machine,
14
+ * with a temporary SSH config so each devbox uses its own key.
3
15
  */
4
- import { exec } from "child_process";
16
+ import { execFile } from "child_process";
5
17
  import { promisify } from "util";
18
+ import { writeFile, unlink } from "fs/promises";
19
+ import { join } from "path";
20
+ import { tmpdir } from "os";
21
+ import { randomUUID } from "crypto";
6
22
  import { getClient } from "../../utils/client.js";
7
23
  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 scpFiles(devboxId, options) {
24
+ import { getSSHKey, getProxyCommand, checkSSHTools, } from "../../utils/ssh.js";
25
+ const execFileAsync = promisify(execFile);
26
+ /**
27
+ * Parse an SCP-style path into its components.
28
+ *
29
+ * Supported formats:
30
+ * user@dbx_id:path -> remote with explicit user
31
+ * dbx_id:path -> remote with default user
32
+ * /local/path -> local (absolute)
33
+ * ./relative -> local (relative)
34
+ * filename -> local (bare filename, no colon)
35
+ */
36
+ export function parseSCPPath(input) {
37
+ // Match [user@]host:path where host is a devbox ID (dbx_*).
38
+ // This avoids false positives on local paths that happen to contain colons.
39
+ const match = input.match(/^(?:([^@/:]+)@)?(dbx_[^@/:]+):(.*)$/);
40
+ if (match) {
41
+ return {
42
+ user: match[1] || undefined,
43
+ host: match[2],
44
+ path: match[3],
45
+ isRemote: true,
46
+ };
47
+ }
48
+ return { path: input, isRemote: false };
49
+ }
50
+ /**
51
+ * Resolve a devbox ID to its SSH info and default user.
52
+ */
53
+ export async function resolveRemote(devboxId) {
54
+ const client = getClient();
55
+ const devbox = await client.devboxes.retrieve(devboxId);
56
+ const defaultUser = devbox.launch_parameters?.user_parameters?.username || "user";
57
+ const sshInfo = await getSSHKey(devboxId);
58
+ if (!sshInfo) {
59
+ throw new Error(`Failed to create SSH key for ${devboxId}`);
60
+ }
61
+ return { devboxId, defaultUser, sshInfo };
62
+ }
63
+ /**
64
+ * Build the SCP command for a single-remote transfer (local <-> devbox).
65
+ */
66
+ export function buildSCPCommand(opts) {
67
+ const scpCommand = [
68
+ "scp",
69
+ "-i",
70
+ opts.sshInfo.keyfilePath,
71
+ "-o",
72
+ `ProxyCommand=${opts.proxyCommand}`,
73
+ "-o",
74
+ "StrictHostKeyChecking=no",
75
+ ];
76
+ if (opts.scpOptions) {
77
+ scpCommand.push(...opts.scpOptions.split(" "));
78
+ }
79
+ // Build src argument
80
+ if (opts.parsedSrc.isRemote) {
81
+ const user = opts.parsedSrc.user || opts.defaultUser;
82
+ scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`);
83
+ }
84
+ else {
85
+ scpCommand.push(opts.parsedSrc.path);
86
+ }
87
+ // Build dst argument
88
+ if (opts.parsedDst.isRemote) {
89
+ const user = opts.parsedDst.user || opts.defaultUser;
90
+ scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`);
91
+ }
92
+ else {
93
+ scpCommand.push(opts.parsedDst.path);
94
+ }
95
+ return scpCommand;
96
+ }
97
+ /**
98
+ * Build the SCP command for a dual-remote transfer (devbox -> devbox).
99
+ * Uses scp -3 to route data through the local machine and a temporary
100
+ * SSH config file so each devbox resolves to its own key/proxy.
101
+ */
102
+ export function buildDualRemoteSCPCommand(opts) {
103
+ const scpCommand = [
104
+ "scp",
105
+ "-3",
106
+ "-F",
107
+ opts.sshConfigPath,
108
+ "-o",
109
+ "StrictHostKeyChecking=no",
110
+ ];
111
+ if (opts.scpOptions) {
112
+ scpCommand.push(...opts.scpOptions.split(" "));
113
+ }
114
+ const srcUser = opts.parsedSrc.user || opts.srcRemote.defaultUser;
115
+ scpCommand.push(`${srcUser}@${opts.srcRemote.sshInfo.url}:${opts.parsedSrc.path}`);
116
+ const dstUser = opts.parsedDst.user || opts.dstRemote.defaultUser;
117
+ scpCommand.push(`${dstUser}@${opts.dstRemote.sshInfo.url}:${opts.parsedDst.path}`);
118
+ return scpCommand;
119
+ }
120
+ /**
121
+ * Generate a temporary SSH config file for dual-remote transfers.
122
+ * Maps each devbox URL to its identity file and proxy command.
123
+ */
124
+ export function generateSCPConfig(remotes, proxyCommand) {
125
+ return remotes
126
+ .map((r) => `Host ${r.sshInfo.url}\n` +
127
+ ` IdentityFile ${r.sshInfo.keyfilePath}\n` +
128
+ ` ProxyCommand ${proxyCommand}\n` +
129
+ ` StrictHostKeyChecking no`)
130
+ .join("\n\n");
131
+ }
132
+ export async function scpFiles(src, dst, options) {
11
133
  try {
12
134
  // Check if SSH tools are available
13
135
  const sshToolsAvailable = await checkSSHTools();
14
136
  if (!sshToolsAvailable) {
15
137
  outputError("SSH tools (ssh, scp, openssl) are not available on this system");
16
138
  }
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");
139
+ const parsedSrc = parseSCPPath(src);
140
+ const parsedDst = parseSCPPath(dst);
141
+ if (!parsedSrc.isRemote && !parsedDst.isRemote) {
142
+ outputError("At least one of src or dst must be a remote devbox path (e.g. dbx_<id>:/path)");
25
143
  }
26
144
  const proxyCommand = getProxyCommand();
27
- const scpCommand = [
28
- "scp",
29
- "-i",
30
- sshInfo.keyfilePath,
31
- "-o",
32
- `ProxyCommand=${proxyCommand}`,
33
- "-o",
34
- "StrictHostKeyChecking=no",
35
- ];
36
- if (options.scpOptions) {
37
- scpCommand.push(...options.scpOptions.split(" "));
38
- }
39
- // Handle remote paths (starting with :)
40
- if (options.src.startsWith(":")) {
41
- scpCommand.push(`${user}@${sshInfo.url}:${options.src.slice(1)}`);
42
- scpCommand.push(options.dst);
43
- }
44
- else {
45
- scpCommand.push(options.src);
46
- if (options.dst.startsWith(":")) {
47
- scpCommand.push(`${user}@${sshInfo.url}:${options.dst.slice(1)}`);
145
+ let scpCommand;
146
+ if (parsedSrc.isRemote && parsedDst.isRemote) {
147
+ // Both sides are remote devboxes — resolve both in parallel
148
+ const [srcRemote, dstRemote] = await Promise.all([
149
+ resolveRemote(parsedSrc.host),
150
+ resolveRemote(parsedDst.host),
151
+ ]);
152
+ // Write a temporary SSH config so scp can find the right key per host
153
+ const configContent = generateSCPConfig([srcRemote, dstRemote], proxyCommand);
154
+ const configPath = join(tmpdir(), `rli-scp-${randomUUID()}.conf`);
155
+ const configHeader = "# Temporary SSH config generated by `rli devbox scp` for a dual-remote transfer.\n" +
156
+ "# Safe to delete.\n\n";
157
+ await writeFile(configPath, configHeader + configContent, {
158
+ mode: 0o600,
159
+ });
160
+ try {
161
+ scpCommand = buildDualRemoteSCPCommand({
162
+ srcRemote,
163
+ dstRemote,
164
+ proxyCommand,
165
+ parsedSrc,
166
+ parsedDst,
167
+ sshConfigPath: configPath,
168
+ scpOptions: options.scpOptions,
169
+ });
170
+ const [cmd, ...args] = scpCommand;
171
+ await execFileAsync(cmd, args);
48
172
  }
49
- else {
50
- scpCommand.push(options.dst);
173
+ finally {
174
+ // Clean up the temp SSH config file.
175
+ // Note: SSH key files in ~/.runloop/ssh_keys/ are intentionally kept —
176
+ // they're shared across rli commands (ssh, scp, etc.).
177
+ await unlink(configPath).catch(() => { });
51
178
  }
52
179
  }
53
- await execAsync(scpCommand.join(" "));
180
+ else {
181
+ // Single remote — one side is local
182
+ const devboxId = parsedSrc.isRemote ? parsedSrc.host : parsedDst.host;
183
+ const remote = await resolveRemote(devboxId);
184
+ scpCommand = buildSCPCommand({
185
+ sshInfo: remote.sshInfo,
186
+ proxyCommand,
187
+ parsedSrc,
188
+ parsedDst,
189
+ defaultUser: remote.defaultUser,
190
+ scpOptions: options.scpOptions,
191
+ });
192
+ const [cmd, ...args] = scpCommand;
193
+ await execFileAsync(cmd, args);
194
+ }
54
195
  // Default: just output the destination for easy scripting
55
196
  if (!options.output || options.output === "text") {
56
- console.log(options.dst);
197
+ console.log(dst);
57
198
  }
58
199
  else {
59
200
  output({
60
- source: options.src,
61
- destination: options.dst,
201
+ source: src,
202
+ destination: dst,
62
203
  }, { format: options.output, defaultFormat: "json" });
63
204
  }
64
205
  }
@@ -6,6 +6,7 @@ import { getClient } from "../../utils/client.js";
6
6
  import { output, outputError } from "../../utils/output.js";
7
7
  import { processUtils } from "../../utils/processUtils.js";
8
8
  import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
9
+ import { openInBrowser } from "../../utils/browser.js";
9
10
  export async function createTunnel(devboxId, options) {
10
11
  try {
11
12
  // Check if SSH tools are available
@@ -60,25 +61,9 @@ export async function createTunnel(devboxId, options) {
60
61
  // Open browser if --open flag is set
61
62
  if (options.open) {
62
63
  // 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);
64
+ setTimeout(() => {
65
+ openInBrowser(tunnelUrl);
66
+ }, 1000); // TODO: Not going to need this soon with tunnels v2
82
67
  }
83
68
  tunnelProcess.on("close", (code) => {
84
69
  console.log("\nTunnel closed.");
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Create gateway config command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ import { validateGatewayConfig } from "../../utils/gatewayConfigValidation.js";
7
+ export async function createGatewayConfig(options) {
8
+ try {
9
+ const client = getClient();
10
+ // Validate that exactly one auth type is specified
11
+ if (options.bearerAuth && options.headerAuth) {
12
+ outputError("Cannot specify both --bearer-auth and --header-auth. Choose one.");
13
+ return;
14
+ }
15
+ // Default to bearer if neither is specified
16
+ const authType = options.headerAuth ? "header" : "bearer";
17
+ // Validate all fields using shared validation
18
+ const validation = validateGatewayConfig({
19
+ name: options.name,
20
+ endpoint: options.endpoint,
21
+ authType,
22
+ authKey: options.headerAuth,
23
+ }, { requireName: true, requireEndpoint: true });
24
+ if (!validation.valid) {
25
+ outputError(validation.errors.join("\n"));
26
+ return;
27
+ }
28
+ const { sanitized } = validation;
29
+ // Build auth mechanism
30
+ const authMechanism = {
31
+ type: sanitized.authType,
32
+ };
33
+ if (sanitized.authType === "header" && sanitized.authKey) {
34
+ authMechanism.key = sanitized.authKey;
35
+ }
36
+ const config = await client.gatewayConfigs.create({
37
+ name: sanitized.name,
38
+ endpoint: sanitized.endpoint,
39
+ auth_mechanism: authMechanism,
40
+ description: options.description?.trim() || undefined,
41
+ });
42
+ // Default: just output the ID for easy scripting
43
+ if (!options.output || options.output === "text") {
44
+ console.log(config.id);
45
+ }
46
+ else {
47
+ output(config, { format: options.output, defaultFormat: "json" });
48
+ }
49
+ }
50
+ catch (error) {
51
+ outputError("Failed to create gateway config", error);
52
+ }
53
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Delete gateway config command
3
+ */
4
+ import { getClient } from "../../utils/client.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ export async function deleteGatewayConfig(id, options = {}) {
7
+ try {
8
+ const client = getClient();
9
+ await client.gatewayConfigs.delete(id);
10
+ // Default: just output the ID for easy scripting
11
+ if (!options.output || options.output === "text") {
12
+ console.log(id);
13
+ }
14
+ else {
15
+ output({ id, status: "deleted" }, { format: options.output, defaultFormat: "json" });
16
+ }
17
+ }
18
+ catch (error) {
19
+ outputError("Failed to delete gateway config", error);
20
+ }
21
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Get gateway config command - supports lookup by ID or name
3
+ */
4
+ import { getGatewayConfigByIdOrName } from "../../services/gatewayConfigService.js";
5
+ import { output, outputError } from "../../utils/output.js";
6
+ export async function getGatewayConfig(options) {
7
+ try {
8
+ const config = await getGatewayConfigByIdOrName(options.id);
9
+ if (!config) {
10
+ outputError(`Gateway config not found: ${options.id}`);
11
+ return;
12
+ }
13
+ output(config, { format: options.output, defaultFormat: "json" });
14
+ }
15
+ catch (error) {
16
+ outputError("Failed to get gateway config", error);
17
+ }
18
+ }