@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.
- package/README.md +21 -7
- package/dist/cli.js +0 -0
- package/dist/commands/blueprint/delete.js +21 -0
- package/dist/commands/blueprint/list.js +226 -174
- package/dist/commands/blueprint/prune.js +13 -28
- package/dist/commands/devbox/create.js +41 -0
- package/dist/commands/devbox/list.js +142 -110
- package/dist/commands/devbox/rsync.js +69 -41
- package/dist/commands/devbox/scp.js +180 -39
- package/dist/commands/devbox/tunnel.js +4 -19
- package/dist/commands/gateway-config/create.js +53 -0
- package/dist/commands/gateway-config/delete.js +21 -0
- package/dist/commands/gateway-config/get.js +18 -0
- package/dist/commands/gateway-config/list.js +493 -0
- package/dist/commands/gateway-config/update.js +70 -0
- package/dist/commands/snapshot/list.js +11 -2
- package/dist/commands/snapshot/prune.js +265 -0
- package/dist/components/BenchmarkMenu.js +23 -3
- package/dist/components/DetailedInfoView.js +20 -0
- package/dist/components/DevboxActionsMenu.js +26 -62
- package/dist/components/DevboxCreatePage.js +763 -15
- package/dist/components/DevboxDetailPage.js +73 -24
- package/dist/components/GatewayConfigCreatePage.js +272 -0
- package/dist/components/LogsViewer.js +6 -40
- package/dist/components/ResourceDetailPage.js +143 -160
- package/dist/components/ResourceListView.js +3 -33
- package/dist/components/ResourcePicker.js +234 -0
- package/dist/components/SecretCreatePage.js +71 -27
- package/dist/components/SettingsMenu.js +12 -2
- package/dist/components/StateHistory.js +1 -20
- package/dist/components/StatusBadge.js +9 -2
- package/dist/components/StreamingLogsViewer.js +8 -42
- package/dist/components/form/FormTextInput.js +4 -2
- package/dist/components/resourceDetailTypes.js +18 -0
- package/dist/hooks/useInputHandler.js +103 -0
- package/dist/router/Router.js +79 -2
- package/dist/screens/BenchmarkDetailScreen.js +163 -0
- package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
- package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
- package/dist/screens/BenchmarkJobListScreen.js +479 -0
- package/dist/screens/BenchmarkListScreen.js +266 -0
- package/dist/screens/BenchmarkMenuScreen.js +6 -0
- package/dist/screens/BenchmarkRunDetailScreen.js +258 -22
- package/dist/screens/BenchmarkRunListScreen.js +21 -1
- package/dist/screens/BlueprintDetailScreen.js +5 -1
- package/dist/screens/DevboxCreateScreen.js +2 -2
- package/dist/screens/GatewayConfigDetailScreen.js +236 -0
- package/dist/screens/GatewayConfigListScreen.js +7 -0
- package/dist/screens/ScenarioRunDetailScreen.js +6 -0
- package/dist/screens/SecretDetailScreen.js +26 -2
- package/dist/screens/SettingsMenuScreen.js +3 -0
- package/dist/screens/SnapshotDetailScreen.js +6 -0
- package/dist/services/agentService.js +42 -0
- package/dist/services/benchmarkJobService.js +122 -0
- package/dist/services/benchmarkService.js +47 -0
- package/dist/services/gatewayConfigService.js +153 -0
- package/dist/services/scenarioService.js +34 -0
- package/dist/store/benchmarkJobStore.js +66 -0
- package/dist/store/benchmarkStore.js +63 -0
- package/dist/store/gatewayConfigStore.js +83 -0
- package/dist/utils/browser.js +22 -0
- package/dist/utils/clipboard.js +41 -0
- package/dist/utils/commands.js +105 -9
- package/dist/utils/gatewayConfigValidation.js +58 -0
- package/dist/utils/time.js +121 -0
- 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 {
|
|
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
|
|
10
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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(
|
|
197
|
+
console.log(dst);
|
|
57
198
|
}
|
|
58
199
|
else {
|
|
59
200
|
output({
|
|
60
|
-
source:
|
|
61
|
-
destination:
|
|
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(
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|