@runloop/rl-cli 1.9.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 +2 -2
- package/dist/commands/devbox/list.js +17 -1
- package/dist/commands/devbox/rsync.js +69 -41
- package/dist/commands/devbox/scp.js +180 -39
- package/dist/commands/gateway-config/create.js +22 -13
- package/dist/commands/gateway-config/get.js +7 -4
- package/dist/commands/gateway-config/list.js +11 -11
- package/dist/commands/gateway-config/update.js +37 -27
- package/dist/components/DevboxActionsMenu.js +17 -1
- package/dist/components/DevboxCreatePage.js +330 -99
- package/dist/components/DevboxDetailPage.js +46 -2
- package/dist/components/GatewayConfigCreatePage.js +35 -28
- package/dist/components/ResourcePicker.js +21 -7
- package/dist/components/SecretCreatePage.js +69 -23
- package/dist/components/SettingsMenu.js +1 -1
- package/dist/screens/GatewayConfigDetailScreen.js +14 -14
- package/dist/screens/SecretDetailScreen.js +26 -2
- package/dist/services/gatewayConfigService.js +39 -0
- package/dist/utils/commands.js +29 -13
- package/dist/utils/gatewayConfigValidation.js +58 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -92,8 +92,8 @@ rli devbox suspend <id> # Suspend a devbox
|
|
|
92
92
|
rli devbox resume <id> # Resume a suspended devbox
|
|
93
93
|
rli devbox shutdown <id> # Shutdown a devbox
|
|
94
94
|
rli devbox ssh <id> # SSH into a devbox
|
|
95
|
-
rli devbox scp <
|
|
96
|
-
rli devbox rsync <
|
|
95
|
+
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
|
|
96
|
+
rli devbox rsync <src> <dst> # Sync files to/from a devbox using rsy...
|
|
97
97
|
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...
|
|
98
98
|
rli devbox read <id> # Read a file from a devbox using the API
|
|
99
99
|
rli devbox write <id> # Write a file to a devbox using the API
|
|
@@ -304,8 +304,10 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
|
|
|
304
304
|
const startIndex = currentPage * PAGE_SIZE;
|
|
305
305
|
const endIndex = startIndex + devboxes.length;
|
|
306
306
|
// Filter operations based on devbox status
|
|
307
|
+
const hasTunnel = !!(selectedDevbox?.tunnel && selectedDevbox.tunnel.tunnel_key);
|
|
307
308
|
const operations = selectedDevbox
|
|
308
|
-
? allOperations
|
|
309
|
+
? allOperations
|
|
310
|
+
.filter((op) => {
|
|
309
311
|
const devboxStatus = selectedDevbox.status;
|
|
310
312
|
if (devboxStatus === "suspended") {
|
|
311
313
|
return op.key === "resume" || op.key === "logs";
|
|
@@ -319,6 +321,20 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
|
|
|
319
321
|
return op.key !== "resume";
|
|
320
322
|
}
|
|
321
323
|
return op.key === "logs" || op.key === "delete";
|
|
324
|
+
})
|
|
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;
|
|
336
|
+
}
|
|
337
|
+
return op;
|
|
322
338
|
})
|
|
323
339
|
: allOperations;
|
|
324
340
|
const closePopup = React.useCallback(() => {
|
|
@@ -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 {
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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(
|
|
82
|
+
console.log(dst);
|
|
55
83
|
}
|
|
56
84
|
else {
|
|
57
85
|
output({
|
|
58
|
-
source:
|
|
59
|
-
destination:
|
|
86
|
+
source: src,
|
|
87
|
+
destination: dst,
|
|
60
88
|
}, { format: options.output, defaultFormat: "json" });
|
|
61
89
|
}
|
|
62
90
|
}
|
|
@@ -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
|
}
|
|
@@ -3,32 +3,41 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { getClient } from "../../utils/client.js";
|
|
5
5
|
import { output, outputError } from "../../utils/output.js";
|
|
6
|
+
import { validateGatewayConfig } from "../../utils/gatewayConfigValidation.js";
|
|
6
7
|
export async function createGatewayConfig(options) {
|
|
7
8
|
try {
|
|
8
9
|
const client = getClient();
|
|
9
|
-
// Validate auth type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
outputError("Invalid auth type. Must be 'bearer' or 'header'");
|
|
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
13
|
return;
|
|
14
14
|
}
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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"));
|
|
18
26
|
return;
|
|
19
27
|
}
|
|
28
|
+
const { sanitized } = validation;
|
|
20
29
|
// Build auth mechanism
|
|
21
30
|
const authMechanism = {
|
|
22
|
-
type: authType,
|
|
31
|
+
type: sanitized.authType,
|
|
23
32
|
};
|
|
24
|
-
if (authType === "header" &&
|
|
25
|
-
authMechanism.key =
|
|
33
|
+
if (sanitized.authType === "header" && sanitized.authKey) {
|
|
34
|
+
authMechanism.key = sanitized.authKey;
|
|
26
35
|
}
|
|
27
36
|
const config = await client.gatewayConfigs.create({
|
|
28
|
-
name:
|
|
29
|
-
endpoint:
|
|
37
|
+
name: sanitized.name,
|
|
38
|
+
endpoint: sanitized.endpoint,
|
|
30
39
|
auth_mechanism: authMechanism,
|
|
31
|
-
description: options.description,
|
|
40
|
+
description: options.description?.trim() || undefined,
|
|
32
41
|
});
|
|
33
42
|
// Default: just output the ID for easy scripting
|
|
34
43
|
if (!options.output || options.output === "text") {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Get gateway config command
|
|
2
|
+
* Get gateway config command - supports lookup by ID or name
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { getGatewayConfigByIdOrName } from "../../services/gatewayConfigService.js";
|
|
5
5
|
import { output, outputError } from "../../utils/output.js";
|
|
6
6
|
export async function getGatewayConfig(options) {
|
|
7
7
|
try {
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const config = await getGatewayConfigByIdOrName(options.id);
|
|
9
|
+
if (!config) {
|
|
10
|
+
outputError(`Gateway config not found: ${options.id}`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
10
13
|
output(config, { format: options.output, defaultFormat: "json" });
|
|
11
14
|
}
|
|
12
15
|
catch (error) {
|
|
@@ -138,13 +138,13 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
key: "edit",
|
|
141
|
-
label: "Edit Gateway Config",
|
|
141
|
+
label: "Edit AI Gateway Config",
|
|
142
142
|
color: colors.warning,
|
|
143
143
|
icon: figures.pointer,
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
key: "delete",
|
|
147
|
-
label: "Delete Gateway Config",
|
|
147
|
+
label: "Delete AI Gateway Config",
|
|
148
148
|
color: colors.error,
|
|
149
149
|
icon: figures.cross,
|
|
150
150
|
},
|
|
@@ -205,7 +205,7 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
205
205
|
switch (operationKey) {
|
|
206
206
|
case "delete":
|
|
207
207
|
await client.gatewayConfigs.delete(config.id);
|
|
208
|
-
setOperationResult(`
|
|
208
|
+
setOperationResult(`AI gateway config "${config.name}" deleted successfully`);
|
|
209
209
|
break;
|
|
210
210
|
}
|
|
211
211
|
}
|
|
@@ -375,8 +375,8 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
375
375
|
});
|
|
376
376
|
// Delete confirmation
|
|
377
377
|
if (showDeleteConfirm && selectedConfig) {
|
|
378
|
-
return (_jsx(ConfirmationPrompt, { title: "Delete Gateway Config", message: `Are you sure you want to delete "${selectedConfig.name}"?`, details: "This action cannot be undone. Any devboxes using this gateway config will no longer have access to it.", breadcrumbItems: [
|
|
379
|
-
{ label: "Gateway Configs" },
|
|
378
|
+
return (_jsx(ConfirmationPrompt, { title: "Delete AI Gateway Config", message: `Are you sure you want to delete "${selectedConfig.name}"?`, details: "This action cannot be undone. Any devboxes using this AI gateway config will no longer have access to it.", breadcrumbItems: [
|
|
379
|
+
{ label: "AI Gateway Configs" },
|
|
380
380
|
{ label: selectedConfig.name || selectedConfig.id },
|
|
381
381
|
{ label: "Delete", active: true },
|
|
382
382
|
], onConfirm: () => {
|
|
@@ -393,7 +393,7 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
393
393
|
const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
|
|
394
394
|
"Operation";
|
|
395
395
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
396
|
-
{ label: "Gateway Configs" },
|
|
396
|
+
{ label: "AI Gateway Configs" },
|
|
397
397
|
{
|
|
398
398
|
label: selectedConfig?.name || selectedConfig?.id || "Config",
|
|
399
399
|
},
|
|
@@ -405,10 +405,10 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
405
405
|
const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
|
|
406
406
|
"Operation";
|
|
407
407
|
const messages = {
|
|
408
|
-
delete: "Deleting gateway config...",
|
|
408
|
+
delete: "Deleting AI gateway config...",
|
|
409
409
|
};
|
|
410
410
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
411
|
-
{ label: "Gateway Configs" },
|
|
411
|
+
{ label: "AI Gateway Configs" },
|
|
412
412
|
{ label: selectedConfig.name || selectedConfig.id },
|
|
413
413
|
{ label: operationLabel, active: true },
|
|
414
414
|
] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." })] }));
|
|
@@ -434,14 +434,14 @@ const ListGatewayConfigsUI = ({ onBack, onExit, }) => {
|
|
|
434
434
|
}
|
|
435
435
|
// Loading state
|
|
436
436
|
if (loading && configs.length === 0) {
|
|
437
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Gateway Configs", active: true }] }), _jsx(SpinnerComponent, { message: "Loading gateway configs..." })] }));
|
|
437
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "AI Gateway Configs", active: true }] }), _jsx(SpinnerComponent, { message: "Loading AI gateway configs..." })] }));
|
|
438
438
|
}
|
|
439
439
|
// Error state
|
|
440
440
|
if (error) {
|
|
441
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Gateway Configs", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list gateway configs", error: error })] }));
|
|
441
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "AI Gateway Configs", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list AI gateway configs", error: error })] }));
|
|
442
442
|
}
|
|
443
443
|
// Main list view
|
|
444
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Gateway Configs", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search gateway configs..." }), !showPopup && (_jsx(Table, { data: configs, keyExtractor: (config) => config.id, selectedIndex: selectedIndex, title: `gateway_configs[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No gateway configs found. Press [c] to create one."] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedConfigItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedConfigItem, operations: operations.map((op) => ({
|
|
444
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "AI Gateway Configs", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search AI gateway configs..." }), !showPopup && (_jsx(Table, { data: configs, keyExtractor: (config) => config.id, selectedIndex: selectedIndex, title: `gateway_configs[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No AI gateway configs found. Press [c] to create one."] }) })), !showPopup && (_jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedConfigItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedConfigItem, operations: operations.map((op) => ({
|
|
445
445
|
key: op.key,
|
|
446
446
|
label: op.label,
|
|
447
447
|
color: op.color,
|