@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* DevboxDetailPage - Detail page for devboxes
|
|
4
4
|
* Uses the generic ResourceDetailPage component with devbox-specific customizations
|
|
@@ -85,7 +85,9 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
|
|
|
85
85
|
];
|
|
86
86
|
// Filter operations based on devbox status
|
|
87
87
|
const getFilteredOperations = (devbox) => {
|
|
88
|
-
|
|
88
|
+
const hasTunnel = !!(devbox.tunnel && devbox.tunnel.tunnel_key);
|
|
89
|
+
return allOperations
|
|
90
|
+
.filter((op) => {
|
|
89
91
|
const status = devbox.status;
|
|
90
92
|
// When suspended: logs and resume
|
|
91
93
|
if (status === "suspended") {
|
|
@@ -103,6 +105,20 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
|
|
|
103
105
|
}
|
|
104
106
|
// Default for transitional states (provisioning, initializing)
|
|
105
107
|
return op.key === "logs" || op.key === "delete";
|
|
108
|
+
})
|
|
109
|
+
.map((op) => {
|
|
110
|
+
// Dynamic tunnel label based on whether tunnel is active
|
|
111
|
+
if (op.key === "tunnel") {
|
|
112
|
+
return hasTunnel
|
|
113
|
+
? {
|
|
114
|
+
...op,
|
|
115
|
+
label: "Tunnel (Active)",
|
|
116
|
+
color: colors.success,
|
|
117
|
+
icon: figures.tick,
|
|
118
|
+
}
|
|
119
|
+
: op;
|
|
120
|
+
}
|
|
121
|
+
return op;
|
|
106
122
|
});
|
|
107
123
|
};
|
|
108
124
|
// Build detail sections for the devbox
|
|
@@ -228,6 +244,22 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
|
|
|
228
244
|
},
|
|
229
245
|
});
|
|
230
246
|
}
|
|
247
|
+
// Tunnel status - always show when running
|
|
248
|
+
if (devbox.tunnel && devbox.tunnel.tunnel_key) {
|
|
249
|
+
const tunnelKey = devbox.tunnel.tunnel_key;
|
|
250
|
+
const authMode = devbox.tunnel.auth_mode;
|
|
251
|
+
const tunnelUrl = `https://{port}-${tunnelKey}.tunnel.runloop.ai`;
|
|
252
|
+
detailFields.push({
|
|
253
|
+
label: "Tunnel",
|
|
254
|
+
value: (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.success, bold: true, children: [figures.tick, " Active"] }), _jsx(Text, { color: colors.textDim, children: " \u2022 " }), _jsx(Text, { color: colors.success, children: tunnelUrl }), authMode === "authenticated" && (_jsx(Text, { color: colors.warning, children: " (authenticated)" }))] })),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (devbox.status === "running") {
|
|
258
|
+
detailFields.push({
|
|
259
|
+
label: "Tunnel",
|
|
260
|
+
value: (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.cross, " Off"] })),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
231
263
|
// Initiator
|
|
232
264
|
if (devbox.initiator_id) {
|
|
233
265
|
detailFields.push({
|
|
@@ -355,6 +387,18 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
|
|
|
355
387
|
}
|
|
356
388
|
lines.push(_jsx(Text, { children: " " }, "launch-space"));
|
|
357
389
|
}
|
|
390
|
+
// Tunnel Information
|
|
391
|
+
if (devbox.tunnel && devbox.tunnel.tunnel_key) {
|
|
392
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Tunnel" }, "tunnel-title"));
|
|
393
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Tunnel Key: ", devbox.tunnel.tunnel_key] }, "tunnel-key"));
|
|
394
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Auth Mode: ", devbox.tunnel.auth_mode] }, "tunnel-auth"));
|
|
395
|
+
const tunnelUrl = `https://{port}-${devbox.tunnel.tunnel_key}.tunnel.runloop.ai`;
|
|
396
|
+
lines.push(_jsxs(Text, { color: colors.success, children: [" ", "Tunnel URL: ", tunnelUrl] }, "tunnel-url"));
|
|
397
|
+
if (devbox.tunnel.auth_token) {
|
|
398
|
+
lines.push(_jsxs(Text, { color: colors.warning, children: [" ", "Auth Token: ", devbox.tunnel.auth_token] }, "tunnel-token"));
|
|
399
|
+
}
|
|
400
|
+
lines.push(_jsx(Text, { children: " " }, "tunnel-space"));
|
|
401
|
+
}
|
|
358
402
|
// Source
|
|
359
403
|
if (devbox.blueprint_id || devbox.snapshot_id) {
|
|
360
404
|
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Source" }, "source-title"));
|
|
@@ -11,6 +11,7 @@ import { NavigationTips } from "./NavigationTips.js";
|
|
|
11
11
|
import { FormTextInput, FormSelect, FormActionButton, useFormSelectNavigation, } from "./form/index.js";
|
|
12
12
|
import { colors } from "../utils/theme.js";
|
|
13
13
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
14
|
+
import { validateGatewayConfig } from "../utils/gatewayConfigValidation.js";
|
|
14
15
|
const authTypes = ["bearer", "header"];
|
|
15
16
|
export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) => {
|
|
16
17
|
const isEditing = !!initialConfig?.id;
|
|
@@ -36,13 +37,20 @@ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) =>
|
|
|
36
37
|
const fields = [
|
|
37
38
|
{
|
|
38
39
|
key: "create",
|
|
39
|
-
label: isEditing
|
|
40
|
+
label: isEditing
|
|
41
|
+
? "Update AI Gateway Config"
|
|
42
|
+
: "Create AI Gateway Config",
|
|
40
43
|
type: "action",
|
|
41
44
|
},
|
|
42
|
-
{
|
|
45
|
+
{
|
|
46
|
+
key: "name",
|
|
47
|
+
label: "Name (required)",
|
|
48
|
+
type: "text",
|
|
49
|
+
placeholder: "my-gateway",
|
|
50
|
+
},
|
|
43
51
|
{
|
|
44
52
|
key: "endpoint",
|
|
45
|
-
label: "Endpoint URL",
|
|
53
|
+
label: "Endpoint URL (required)",
|
|
46
54
|
type: "text",
|
|
47
55
|
placeholder: "https://api.example.com",
|
|
48
56
|
},
|
|
@@ -156,35 +164,34 @@ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) =>
|
|
|
156
164
|
}
|
|
157
165
|
}, { isActive: true });
|
|
158
166
|
const handleCreate = async () => {
|
|
159
|
-
// Validate
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (formData.auth_type === "header" && !formData.auth_key.trim()) {
|
|
169
|
-
setError(new Error("Auth header key is required for header auth type"));
|
|
167
|
+
// Validate using shared validation
|
|
168
|
+
const validation = validateGatewayConfig({
|
|
169
|
+
name: formData.name,
|
|
170
|
+
endpoint: formData.endpoint,
|
|
171
|
+
authType: formData.auth_type,
|
|
172
|
+
authKey: formData.auth_key,
|
|
173
|
+
}, { requireName: true, requireEndpoint: true });
|
|
174
|
+
if (!validation.valid) {
|
|
175
|
+
setError(new Error(validation.errors.join("\n")));
|
|
170
176
|
return;
|
|
171
177
|
}
|
|
178
|
+
const { sanitized } = validation;
|
|
172
179
|
setCreating(true);
|
|
173
180
|
setError(null);
|
|
174
181
|
try {
|
|
175
182
|
const client = getClient();
|
|
176
183
|
const authMechanism = {
|
|
177
|
-
type:
|
|
184
|
+
type: sanitized.authType,
|
|
178
185
|
};
|
|
179
|
-
if (
|
|
180
|
-
authMechanism.key =
|
|
186
|
+
if (sanitized.authType === "header" && sanitized.authKey) {
|
|
187
|
+
authMechanism.key = sanitized.authKey;
|
|
181
188
|
}
|
|
182
189
|
let config;
|
|
183
190
|
if (isEditing && initialConfig?.id) {
|
|
184
191
|
// Update existing config
|
|
185
192
|
config = await client.gatewayConfigs.update(initialConfig.id, {
|
|
186
|
-
name:
|
|
187
|
-
endpoint:
|
|
193
|
+
name: sanitized.name,
|
|
194
|
+
endpoint: sanitized.endpoint,
|
|
188
195
|
auth_mechanism: authMechanism,
|
|
189
196
|
description: formData.description.trim() || undefined,
|
|
190
197
|
});
|
|
@@ -192,8 +199,8 @@ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) =>
|
|
|
192
199
|
else {
|
|
193
200
|
// Create new config
|
|
194
201
|
config = await client.gatewayConfigs.create({
|
|
195
|
-
name:
|
|
196
|
-
endpoint:
|
|
202
|
+
name: sanitized.name,
|
|
203
|
+
endpoint: sanitized.endpoint,
|
|
197
204
|
auth_mechanism: authMechanism,
|
|
198
205
|
description: formData.description.trim() || undefined,
|
|
199
206
|
});
|
|
@@ -210,16 +217,16 @@ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) =>
|
|
|
210
217
|
// Result screen
|
|
211
218
|
if (result) {
|
|
212
219
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
213
|
-
{ label: "Gateway Configs" },
|
|
220
|
+
{ label: "AI Gateway Configs" },
|
|
214
221
|
{ label: isEditing ? "Update" : "Create", active: true },
|
|
215
|
-
] }), _jsx(SuccessMessage, { message: `
|
|
222
|
+
] }), _jsx(SuccessMessage, { message: `AI gateway config ${isEditing ? "updated" : "created"} successfully!` }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name || "(none)"] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Endpoint: ", result.endpoint] }) })] }), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Return to list" }] })] }));
|
|
216
223
|
}
|
|
217
224
|
// Error screen
|
|
218
225
|
if (error) {
|
|
219
226
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
220
|
-
{ label: "Gateway Configs" },
|
|
227
|
+
{ label: "AI Gateway Configs" },
|
|
221
228
|
{ label: isEditing ? "Update" : "Create", active: true },
|
|
222
|
-
] }), _jsx(ErrorMessage, { message: `Failed to ${isEditing ? "update" : "create"} gateway config`, error: error }), _jsx(NavigationTips, { tips: [
|
|
229
|
+
] }), _jsx(ErrorMessage, { message: `Failed to ${isEditing ? "update" : "create"} AI gateway config`, error: error }), _jsx(NavigationTips, { tips: [
|
|
223
230
|
{ key: "Enter/r", label: "Retry" },
|
|
224
231
|
{ key: "q/esc", label: "Cancel" },
|
|
225
232
|
] })] }));
|
|
@@ -227,13 +234,13 @@ export const GatewayConfigCreatePage = ({ onBack, onCreate, initialConfig, }) =>
|
|
|
227
234
|
// Creating screen
|
|
228
235
|
if (creating) {
|
|
229
236
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
230
|
-
{ label: "Gateway Configs" },
|
|
237
|
+
{ label: "AI Gateway Configs" },
|
|
231
238
|
{ label: isEditing ? "Update" : "Create", active: true },
|
|
232
|
-
] }), _jsx(SpinnerComponent, { message: `${isEditing ? "Updating" : "Creating"} gateway config...` })] }));
|
|
239
|
+
] }), _jsx(SpinnerComponent, { message: `${isEditing ? "Updating" : "Creating"} AI gateway config...` })] }));
|
|
233
240
|
}
|
|
234
241
|
// Form screen
|
|
235
242
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
236
|
-
{ label: "Gateway Configs" },
|
|
243
|
+
{ label: "AI Gateway Configs" },
|
|
237
244
|
{ label: isEditing ? "Update" : "Create", active: true },
|
|
238
245
|
] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
|
|
239
246
|
const isActive = currentField === field.key;
|
|
@@ -31,14 +31,22 @@ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [
|
|
|
31
31
|
onSearchClear: () => setSelectedIndex(0),
|
|
32
32
|
});
|
|
33
33
|
// Calculate overhead for viewport height
|
|
34
|
-
|
|
34
|
+
// Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13
|
|
35
|
+
const overhead = 13 + search.getSearchOverhead();
|
|
35
36
|
const { viewportHeight, terminalWidth } = useViewportHeight({
|
|
36
37
|
overhead,
|
|
37
38
|
minHeight: 5,
|
|
38
39
|
});
|
|
39
40
|
const PAGE_SIZE = viewportHeight;
|
|
40
|
-
//
|
|
41
|
-
|
|
41
|
+
// Resolve columns - support both static array and function that receives terminalWidth
|
|
42
|
+
const resolvedColumns = React.useMemo(() => {
|
|
43
|
+
if (!config.columns)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof config.columns === "function") {
|
|
46
|
+
return config.columns(terminalWidth);
|
|
47
|
+
}
|
|
48
|
+
return config.columns;
|
|
49
|
+
}, [config.columns, terminalWidth]);
|
|
42
50
|
// Store fetchPage in a ref to avoid dependency issues
|
|
43
51
|
const fetchPageRef = React.useRef(config.fetchPage);
|
|
44
52
|
React.useEffect(() => {
|
|
@@ -154,6 +162,9 @@ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [
|
|
|
154
162
|
handleConfirm();
|
|
155
163
|
}
|
|
156
164
|
}
|
|
165
|
+
else if (input === "c" && config.onCreateNew) {
|
|
166
|
+
config.onCreateNew();
|
|
167
|
+
}
|
|
157
168
|
else if (input === "/") {
|
|
158
169
|
search.enterSearchMode();
|
|
159
170
|
}
|
|
@@ -178,7 +189,7 @@ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [
|
|
|
178
189
|
// Calculate pagination info for display
|
|
179
190
|
const startIndex = currentPage * PAGE_SIZE;
|
|
180
191
|
const endIndex = Math.min(startIndex + PAGE_SIZE, totalCount);
|
|
181
|
-
return (_jsxs(_Fragment, { children: [config.breadcrumbItems && _jsx(Breadcrumb, { items: config.breadcrumbItems }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: config.searchPlaceholder || "Search..." }),
|
|
192
|
+
return (_jsxs(_Fragment, { children: [config.breadcrumbItems && _jsx(Breadcrumb, { items: config.breadcrumbItems }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: config.searchPlaceholder || "Search..." }), resolvedColumns ? (_jsx(Table, { data: items, keyExtractor: config.getItemId, selectedIndex: selectedIndex, title: `${config.title.toLowerCase()}[${totalCount}]${config.mode === "multi" ? ` (${selectedIds.size} selected)` : ""}`, columns: config.mode === "multi"
|
|
182
193
|
? [
|
|
183
194
|
// Prepend checkbox column for multi-select mode
|
|
184
195
|
createComponentColumn("_selection", "", (row) => {
|
|
@@ -187,11 +198,11 @@ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [
|
|
|
187
198
|
? figures.checkboxOn
|
|
188
199
|
: figures.checkboxOff, " "] }));
|
|
189
200
|
}, { width: 3 }),
|
|
190
|
-
...
|
|
201
|
+
...resolvedColumns,
|
|
191
202
|
]
|
|
192
|
-
:
|
|
203
|
+
: resolvedColumns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (
|
|
193
204
|
// Fallback simple list view if no columns provided
|
|
194
|
-
_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, paddingY: 0, children: items.length === 0 ? (_jsx(Box, { paddingY: 1, children: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found"] }) })) : (items.map((item, index) => {
|
|
205
|
+
_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, paddingY: 0, children: items.length === 0 ? (_jsx(Box, { paddingY: 1, children: _jsxs(Text, { color: colors.textDim, children: [figures.info, " ", config.emptyMessage || "No items found", config.onCreateNew ? " Press [c] to create one." : ""] }) })) : (items.map((item, index) => {
|
|
195
206
|
const isHighlighted = index === selectedIndex;
|
|
196
207
|
const id = config.getItemId(item);
|
|
197
208
|
const label = config.getItemLabel(item);
|
|
@@ -214,6 +225,9 @@ export function ResourcePicker({ config, onSelect, onCancel, initialSelected = [
|
|
|
214
225
|
label: config.mode === "single" ? "Select" : "Confirm",
|
|
215
226
|
condition: canConfirm,
|
|
216
227
|
},
|
|
228
|
+
...(config.onCreateNew
|
|
229
|
+
? [{ key: "c", label: config.createNewLabel || "Create new" }]
|
|
230
|
+
: []),
|
|
217
231
|
{ key: "/", label: "Search" },
|
|
218
232
|
{ key: "Esc", label: "Cancel" },
|
|
219
233
|
] })] }));
|
|
@@ -14,10 +14,11 @@ import { NavigationTips } from "./NavigationTips.js";
|
|
|
14
14
|
import { FormTextInput, FormActionButton } from "./form/index.js";
|
|
15
15
|
import { colors } from "../utils/theme.js";
|
|
16
16
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
17
|
-
export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
17
|
+
export const SecretCreatePage = ({ onBack, onCreate, initialSecret, }) => {
|
|
18
|
+
const isUpdating = !!initialSecret;
|
|
18
19
|
const [currentField, setCurrentField] = React.useState("submit");
|
|
19
20
|
const [formData, setFormData] = React.useState({
|
|
20
|
-
name: "",
|
|
21
|
+
name: initialSecret?.name || "",
|
|
21
22
|
value: "",
|
|
22
23
|
});
|
|
23
24
|
const [submitting, setSubmitting] = React.useState(false);
|
|
@@ -25,9 +26,17 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
25
26
|
const [error, setError] = React.useState(null);
|
|
26
27
|
const [validationError, setValidationError] = React.useState(null);
|
|
27
28
|
const fields = [
|
|
28
|
-
{
|
|
29
|
+
{
|
|
30
|
+
key: "submit",
|
|
31
|
+
label: isUpdating ? "Update Secret" : "Create Secret",
|
|
32
|
+
type: "action",
|
|
33
|
+
},
|
|
29
34
|
{ key: "name", label: "Name (required)", type: "text" },
|
|
30
|
-
{
|
|
35
|
+
{
|
|
36
|
+
key: "value",
|
|
37
|
+
label: isUpdating ? "New Value (required)" : "Value (required)",
|
|
38
|
+
type: "password",
|
|
39
|
+
},
|
|
31
40
|
];
|
|
32
41
|
const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
|
|
33
42
|
// Handle Ctrl+C to exit
|
|
@@ -78,13 +87,30 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
78
87
|
return;
|
|
79
88
|
}
|
|
80
89
|
// Navigation between fields (up/down arrows and tab/shift+tab)
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// In update mode, skip the name field (it's read-only)
|
|
91
|
+
const getNextFieldIndex = (direction) => {
|
|
92
|
+
let nextIdx = direction === "up" ? currentFieldIndex - 1 : currentFieldIndex + 1;
|
|
93
|
+
while (nextIdx >= 0 && nextIdx < fields.length) {
|
|
94
|
+
if (isUpdating && fields[nextIdx].key === "name") {
|
|
95
|
+
nextIdx = direction === "up" ? nextIdx - 1 : nextIdx + 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return nextIdx;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
if (key.upArrow || (key.tab && key.shift)) {
|
|
103
|
+
const nextIdx = getNextFieldIndex("up");
|
|
104
|
+
if (nextIdx !== null) {
|
|
105
|
+
setCurrentField(fields[nextIdx].key);
|
|
106
|
+
}
|
|
83
107
|
return;
|
|
84
108
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
if (key.downArrow || (key.tab && !key.shift)) {
|
|
110
|
+
const nextIdx = getNextFieldIndex("down");
|
|
111
|
+
if (nextIdx !== null) {
|
|
112
|
+
setCurrentField(fields[nextIdx].key);
|
|
113
|
+
}
|
|
88
114
|
return;
|
|
89
115
|
}
|
|
90
116
|
});
|
|
@@ -105,10 +131,19 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
105
131
|
setValidationError(null);
|
|
106
132
|
try {
|
|
107
133
|
const client = getClient();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
let secret;
|
|
135
|
+
if (isUpdating) {
|
|
136
|
+
// Update existing secret by name
|
|
137
|
+
secret = (await client.secrets.update(formData.name.trim(), {
|
|
138
|
+
value: formData.value,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
secret = (await client.secrets.create({
|
|
143
|
+
name: formData.name.trim(),
|
|
144
|
+
value: formData.value,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
112
147
|
setResult(secret);
|
|
113
148
|
}
|
|
114
149
|
catch (err) {
|
|
@@ -123,16 +158,21 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
123
158
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
124
159
|
{ label: "Settings" },
|
|
125
160
|
{ label: "Secrets" },
|
|
126
|
-
{ label: "Create", active: true },
|
|
127
|
-
] }), _jsx(SuccessMessage, { message:
|
|
161
|
+
{ label: isUpdating ? "Update" : "Create", active: true },
|
|
162
|
+
] }), _jsx(SuccessMessage, { message: `Secret ${isUpdating ? "updated" : "created"} successfully!` }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name] }) })] }), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsxs(Text, { color: colors.info, children: [figures.info, " The secret value has been securely stored and cannot be retrieved."] }) }), _jsx(NavigationTips, { tips: [
|
|
163
|
+
{
|
|
164
|
+
key: "Enter/q/esc",
|
|
165
|
+
label: isUpdating ? "Return to details" : "View secret details",
|
|
166
|
+
},
|
|
167
|
+
] })] }));
|
|
128
168
|
}
|
|
129
169
|
// Error screen
|
|
130
170
|
if (error) {
|
|
131
171
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
132
172
|
{ label: "Settings" },
|
|
133
173
|
{ label: "Secrets" },
|
|
134
|
-
{ label: "Create", active: true },
|
|
135
|
-
] }), _jsx(ErrorMessage, { message:
|
|
174
|
+
{ label: isUpdating ? "Update" : "Create", active: true },
|
|
175
|
+
] }), _jsx(ErrorMessage, { message: `Failed to ${isUpdating ? "update" : "create"} secret`, error: error }), _jsx(NavigationTips, { tips: [
|
|
136
176
|
{ key: "Enter/r", label: "Retry" },
|
|
137
177
|
{ key: "q/esc", label: "Cancel" },
|
|
138
178
|
] })] }));
|
|
@@ -142,20 +182,26 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
142
182
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
143
183
|
{ label: "Settings" },
|
|
144
184
|
{ label: "Secrets" },
|
|
145
|
-
{ label: "Create", active: true },
|
|
146
|
-
] }), _jsx(SpinnerComponent, { message: "Creating secret
|
|
185
|
+
{ label: isUpdating ? "Update" : "Create", active: true },
|
|
186
|
+
] }), _jsx(SpinnerComponent, { message: `${isUpdating ? "Updating" : "Creating"} secret...` })] }));
|
|
147
187
|
}
|
|
148
188
|
// Form screen
|
|
149
189
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
150
190
|
{ label: "Settings" },
|
|
151
191
|
{ label: "Secrets" },
|
|
152
|
-
{ label: "Create", active: true },
|
|
153
|
-
] }), _jsx(Box, { borderStyle: "round", borderColor: colors.info, paddingX: 1, paddingY: 0, marginBottom: 1, children: _jsxs(Text, { color: colors.info, children: [figures.info, " ", _jsx(Text, { bold: true, children: "Note:" }), " Secret values are", " ", _jsx(Text, { bold: true, children: "write-only" }), ". Once
|
|
192
|
+
{ label: isUpdating ? "Update" : "Create", active: true },
|
|
193
|
+
] }), _jsx(Box, { borderStyle: "round", borderColor: colors.info, paddingX: 1, paddingY: 0, marginBottom: 1, children: _jsxs(Text, { color: colors.info, children: [figures.info, " ", _jsx(Text, { bold: true, children: "Note:" }), " Secret values are", " ", _jsx(Text, { bold: true, children: "write-only" }), ". Once stored, the value cannot be retrieved or viewed.", " ", isUpdating
|
|
194
|
+
? "Enter a new value to replace the current one."
|
|
195
|
+
: "You can update a secret's value later."] }) }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
|
|
154
196
|
const isActive = currentField === field.key;
|
|
155
197
|
if (field.type === "action") {
|
|
156
|
-
return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint:
|
|
198
|
+
return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: `[Enter to ${isUpdating ? "update" : "create"}]` }, field.key));
|
|
157
199
|
}
|
|
158
200
|
if (field.type === "text") {
|
|
201
|
+
// In update mode, name is read-only
|
|
202
|
+
if (isUpdating && field.key === "name") {
|
|
203
|
+
return (_jsxs(Box, { marginBottom: 0, children: [_jsxs(Text, { color: colors.textDim, children: [" ", field.label, ":", " "] }), _jsx(Text, { color: colors.text, bold: true, children: formData.name })] }, field.key));
|
|
204
|
+
}
|
|
159
205
|
const value = formData[field.key];
|
|
160
206
|
const hasError = field.key === "name" && validationError === "Name is required";
|
|
161
207
|
return (_jsx(FormTextInput, { label: field.label, value: value, onChange: (newValue) => {
|
|
@@ -177,7 +223,7 @@ export const SecretCreatePage = ({ onBack, onCreate, }) => {
|
|
|
177
223
|
}
|
|
178
224
|
return null;
|
|
179
225
|
}) }), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
180
|
-
{ key: "Enter", label: "Create" },
|
|
226
|
+
{ key: "Enter", label: isUpdating ? "Update" : "Create" },
|
|
181
227
|
{ key: "q", label: "Cancel" },
|
|
182
228
|
] })] }));
|
|
183
229
|
};
|
|
@@ -61,23 +61,23 @@ export function GatewayConfigDetailScreen({ gatewayConfigId, }) {
|
|
|
61
61
|
// Show loading state while fetching or before fetch starts
|
|
62
62
|
if (!config && gatewayConfigId && !error) {
|
|
63
63
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
64
|
-
{ label: "Gateway Configs" },
|
|
64
|
+
{ label: "AI Gateway Configs" },
|
|
65
65
|
{ label: "Loading...", active: true },
|
|
66
|
-
] }), _jsx(SpinnerComponent, { message: "Loading gateway config details..." })] }));
|
|
66
|
+
] }), _jsx(SpinnerComponent, { message: "Loading AI gateway config details..." })] }));
|
|
67
67
|
}
|
|
68
68
|
// Show error state if fetch failed
|
|
69
69
|
if (error && !config) {
|
|
70
70
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
71
|
-
{ label: "Gateway Configs" },
|
|
71
|
+
{ label: "AI Gateway Configs" },
|
|
72
72
|
{ label: "Error", active: true },
|
|
73
|
-
] }), _jsx(ErrorMessage, { message: "Failed to load gateway config details", error: error })] }));
|
|
73
|
+
] }), _jsx(ErrorMessage, { message: "Failed to load AI gateway config details", error: error })] }));
|
|
74
74
|
}
|
|
75
75
|
// Show error if no config found
|
|
76
76
|
if (!config) {
|
|
77
77
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
78
|
-
{ label: "Gateway Configs" },
|
|
78
|
+
{ label: "AI Gateway Configs" },
|
|
79
79
|
{ label: "Not Found", active: true },
|
|
80
|
-
] }), _jsx(ErrorMessage, { message: `
|
|
80
|
+
] }), _jsx(ErrorMessage, { message: `AI gateway config ${gatewayConfigId || "unknown"} not found`, error: new Error("AI gateway config not found") })] }));
|
|
81
81
|
}
|
|
82
82
|
// Build detail sections
|
|
83
83
|
const detailSections = [];
|
|
@@ -135,14 +135,14 @@ export function GatewayConfigDetailScreen({ gatewayConfigId, }) {
|
|
|
135
135
|
const operations = [
|
|
136
136
|
{
|
|
137
137
|
key: "edit",
|
|
138
|
-
label: "Edit Gateway Config",
|
|
138
|
+
label: "Edit AI Gateway Config",
|
|
139
139
|
color: colors.warning,
|
|
140
140
|
icon: figures.pointer,
|
|
141
141
|
shortcut: "e",
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
key: "delete",
|
|
145
|
-
label: "Delete Gateway Config",
|
|
145
|
+
label: "Delete AI Gateway Config",
|
|
146
146
|
color: colors.error,
|
|
147
147
|
icon: figures.cross,
|
|
148
148
|
shortcut: "d",
|
|
@@ -179,7 +179,7 @@ export function GatewayConfigDetailScreen({ gatewayConfigId, }) {
|
|
|
179
179
|
const buildDetailLines = (gc) => {
|
|
180
180
|
const lines = [];
|
|
181
181
|
// Core Information
|
|
182
|
-
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Gateway Config Details" }, "core-title"));
|
|
182
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "AI Gateway Config Details" }, "core-title"));
|
|
183
183
|
lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", gc.id] }, "core-id"));
|
|
184
184
|
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Name: ", gc.name] }, "core-name"));
|
|
185
185
|
if (gc.description) {
|
|
@@ -218,8 +218,8 @@ export function GatewayConfigDetailScreen({ gatewayConfigId, }) {
|
|
|
218
218
|
}
|
|
219
219
|
// Show delete confirmation
|
|
220
220
|
if (showDeleteConfirm && config) {
|
|
221
|
-
return (_jsx(ConfirmationPrompt, { title: "Delete Gateway Config", message: `Are you sure you want to delete "${config.name || config.id}"?`, details: "This action cannot be undone. Any devboxes using this gateway config will no longer have access to it.", breadcrumbItems: [
|
|
222
|
-
{ label: "Gateway Configs" },
|
|
221
|
+
return (_jsx(ConfirmationPrompt, { title: "Delete AI Gateway Config", message: `Are you sure you want to delete "${config.name || config.id}"?`, details: "This action cannot be undone. Any devboxes using this AI gateway config will no longer have access to it.", breadcrumbItems: [
|
|
222
|
+
{ label: "AI Gateway Configs" },
|
|
223
223
|
{ label: config.name || config.id },
|
|
224
224
|
{ label: "Delete", active: true },
|
|
225
225
|
], onConfirm: executeDelete, onCancel: () => setShowDeleteConfirm(false) }));
|
|
@@ -227,10 +227,10 @@ export function GatewayConfigDetailScreen({ gatewayConfigId, }) {
|
|
|
227
227
|
// Show deleting state
|
|
228
228
|
if (deleting) {
|
|
229
229
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
230
|
-
{ label: "Gateway Configs" },
|
|
230
|
+
{ label: "AI Gateway Configs" },
|
|
231
231
|
{ label: config.name || config.id },
|
|
232
232
|
{ label: "Deleting...", active: true },
|
|
233
|
-
] }), _jsx(SpinnerComponent, { message: "Deleting gateway config..." })] }));
|
|
233
|
+
] }), _jsx(SpinnerComponent, { message: "Deleting AI gateway config..." })] }));
|
|
234
234
|
}
|
|
235
|
-
return (_jsx(ResourceDetailPage, { resource: config, resourceType: "Gateway Configs", getDisplayName: (gc) => gc.name || gc.id, getId: (gc) => gc.id, getStatus: () => "active", detailSections: detailSections, operations: operations, onOperation: handleOperation, onBack: goBack, buildDetailLines: buildDetailLines }));
|
|
235
|
+
return (_jsx(ResourceDetailPage, { resource: config, resourceType: "AI Gateway Configs", getDisplayName: (gc) => gc.name || gc.id, getId: (gc) => gc.id, getStatus: () => "active", detailSections: detailSections, operations: operations, onOperation: handleOperation, onBack: goBack, buildDetailLines: buildDetailLines }));
|
|
236
236
|
}
|
|
@@ -13,6 +13,7 @@ import { SpinnerComponent } from "../components/Spinner.js";
|
|
|
13
13
|
import { ErrorMessage } from "../components/ErrorMessage.js";
|
|
14
14
|
import { Breadcrumb } from "../components/Breadcrumb.js";
|
|
15
15
|
import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js";
|
|
16
|
+
import { SecretCreatePage } from "../components/SecretCreatePage.js";
|
|
16
17
|
import { colors } from "../utils/theme.js";
|
|
17
18
|
export function SecretDetailScreen({ secretId }) {
|
|
18
19
|
const { goBack } = useNavigation();
|
|
@@ -21,6 +22,7 @@ export function SecretDetailScreen({ secretId }) {
|
|
|
21
22
|
const [secret, setSecret] = React.useState(null);
|
|
22
23
|
const [deleting, setDeleting] = React.useState(false);
|
|
23
24
|
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
|
25
|
+
const [showUpdateForm, setShowUpdateForm] = React.useState(false);
|
|
24
26
|
// Fetch secret from API
|
|
25
27
|
React.useEffect(() => {
|
|
26
28
|
if (secretId && !loading && !secret) {
|
|
@@ -108,8 +110,15 @@ export function SecretDetailScreen({ secretId }) {
|
|
|
108
110
|
},
|
|
109
111
|
],
|
|
110
112
|
});
|
|
111
|
-
// Operations available for secrets
|
|
113
|
+
// Operations available for secrets
|
|
112
114
|
const operations = [
|
|
115
|
+
{
|
|
116
|
+
key: "update",
|
|
117
|
+
label: "Update Value",
|
|
118
|
+
color: colors.warning,
|
|
119
|
+
icon: figures.pointer,
|
|
120
|
+
shortcut: "u",
|
|
121
|
+
},
|
|
113
122
|
{
|
|
114
123
|
key: "delete",
|
|
115
124
|
label: "Delete Secret",
|
|
@@ -121,6 +130,9 @@ export function SecretDetailScreen({ secretId }) {
|
|
|
121
130
|
// Handle operation selection
|
|
122
131
|
const handleOperation = async (operation, _resource) => {
|
|
123
132
|
switch (operation) {
|
|
133
|
+
case "update":
|
|
134
|
+
setShowUpdateForm(true);
|
|
135
|
+
break;
|
|
124
136
|
case "delete":
|
|
125
137
|
// Show confirmation dialog
|
|
126
138
|
setShowDeleteConfirm(true);
|
|
@@ -160,7 +172,7 @@ export function SecretDetailScreen({ secretId }) {
|
|
|
160
172
|
// Security Notice
|
|
161
173
|
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Security Notice" }, "security-title"));
|
|
162
174
|
lines.push(_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "Secret values are write-only and cannot be retrieved."] }, "security-notice"));
|
|
163
|
-
lines.push(_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "
|
|
175
|
+
lines.push(_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "Use the Update Value operation to change a secret's value."] }, "security-notice2"));
|
|
164
176
|
lines.push(_jsx(Text, { children: " " }, "security-space"));
|
|
165
177
|
// Raw JSON (without value)
|
|
166
178
|
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Raw JSON" }, "json-title"));
|
|
@@ -176,6 +188,18 @@ export function SecretDetailScreen({ secretId }) {
|
|
|
176
188
|
});
|
|
177
189
|
return lines;
|
|
178
190
|
};
|
|
191
|
+
// Show update form
|
|
192
|
+
if (showUpdateForm && secret) {
|
|
193
|
+
return (_jsx(SecretCreatePage, { onBack: () => setShowUpdateForm(false), onCreate: (updatedSecret) => {
|
|
194
|
+
// Refresh the secret data
|
|
195
|
+
setSecret({
|
|
196
|
+
...secret,
|
|
197
|
+
...updatedSecret,
|
|
198
|
+
update_time_ms: Date.now(),
|
|
199
|
+
});
|
|
200
|
+
setShowUpdateForm(false);
|
|
201
|
+
}, initialSecret: { id: secret.id, name: secret.name } }));
|
|
202
|
+
}
|
|
179
203
|
// Show delete confirmation
|
|
180
204
|
if (showDeleteConfirm && secret) {
|
|
181
205
|
return (_jsx(ConfirmationPrompt, { title: "Delete Secret", message: `Are you sure you want to delete "${secret.name}"?`, details: "This action cannot be undone. Any devboxes using this secret will no longer have access to it.", breadcrumbItems: [
|