diff --git a/README.md b/README.md index 500bca43..4f0c7345 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,16 @@ rli secret update # Update a secret value (value from std rli secret delete # Delete a secret ``` +### Gateway-config Commands (alias: `gwc`) + +```bash +rli gateway-config list # List gateway configurations +rli gateway-config create # Create a new gateway configuration +rli gateway-config get # Get gateway configuration details +rli gateway-config update # Update a gateway configuration +rli gateway-config delete # Delete a gateway configuration +``` + ### Mcp Commands ```bash diff --git a/package.json b/package.json index aab2c3c6..c8b0c79e 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", - "@runloop/api-client": "1.3.1", + "@runloop/api-client": "1.6.0", "@types/express": "^5.0.3", "chalk": "^5.3.0", "commander": "^14.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8fe7ff1..57c41436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^1.19.1 version: 1.25.3(hono@4.11.7)(zod@4.3.6) '@runloop/api-client': - specifier: 1.3.1 - version: 1.3.1 + specifier: 1.6.0 + version: 1.6.0 '@types/express': specifier: ^5.0.3 version: 5.0.6 @@ -679,8 +679,8 @@ packages: '@cfworker/json-schema': optional: true - '@runloop/api-client@1.3.1': - resolution: {integrity: sha512-OmMkJyzrxPTJ1Ex/+DPra9VvpRXW1BgTQgr7qEFYCNPpXUPTNMn18AA1MA0xGZY1qECDDdLaWj49nkAeq0hnpQ==} + '@runloop/api-client@1.6.0': + resolution: {integrity: sha512-zoOfR45kImlBi/2vp56d/rrkXtxu24CoC36lTc6xLJx69LWJapg9gfCmrdhnCfgkOGhnu9BLx3P4fcuw8Egl2w==} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3807,7 +3807,7 @@ snapshots: - hono - supports-color - '@runloop/api-client@1.3.1': + '@runloop/api-client@1.6.0': dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index 5ceb9e56..f626b90a 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -23,6 +23,7 @@ interface CreateOptions { root?: boolean; user?: string; networkPolicy?: string; + gateways?: string[]; output?: string; } @@ -71,6 +72,45 @@ function parseCodeMounts(codeMounts: string[]): unknown[] { }); } +// Parse gateways from ENV_PREFIX=gateway,secret format +function parseGateways( + gateways: string[], +): Record { + const result: Record = {}; + for (const gateway of gateways) { + const eqIndex = gateway.indexOf("="); + if (eqIndex === -1) { + throw new Error( + `Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`, + ); + } + const envPrefix = gateway.substring(0, eqIndex); + const valueStr = gateway.substring(eqIndex + 1); + + // Split by comma to get gateway and secret + const commaIndex = valueStr.indexOf(","); + if (commaIndex === -1) { + throw new Error( + `Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`, + ); + } + const gatewayIdOrName = valueStr.substring(0, commaIndex); + const secretIdOrName = valueStr.substring(commaIndex + 1); + + if (!envPrefix || !gatewayIdOrName || !secretIdOrName) { + throw new Error( + `Invalid gateway format: ${gateway}. Expected ENV_PREFIX=gateway_id_or_name,secret_id_or_name`, + ); + } + + result[envPrefix] = { + gateway: gatewayIdOrName, + secret: secretIdOrName, + }; + } + return result; +} + export async function createDevbox(options: CreateOptions = {}) { try { const client = getClient(); @@ -173,6 +213,11 @@ export async function createDevbox(options: CreateOptions = {}) { createRequest.secrets = parseSecrets(options.secrets); } + // Handle gateways + if (options.gateways && options.gateways.length > 0) { + createRequest.gateways = parseGateways(options.gateways); + } + if (Object.keys(launchParameters).length > 0) { createRequest.launch_parameters = launchParameters; } diff --git a/src/commands/gateway-config/create.ts b/src/commands/gateway-config/create.ts new file mode 100644 index 00000000..363c1a86 --- /dev/null +++ b/src/commands/gateway-config/create.ts @@ -0,0 +1,58 @@ +/** + * Create gateway config command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface CreateOptions { + name: string; + endpoint: string; + authType: string; + authKey?: string; + description?: string; + output?: string; +} + +export async function createGatewayConfig(options: CreateOptions) { + try { + const client = getClient(); + + // Validate auth type + const authType = options.authType.toLowerCase(); + if (authType !== "bearer" && authType !== "header") { + outputError("Invalid auth type. Must be 'bearer' or 'header'"); + return; + } + + // Validate auth key is provided for header type + if (authType === "header" && !options.authKey) { + outputError("--auth-key is required when auth-type is 'header'"); + return; + } + + // Build auth mechanism + const authMechanism: { type: string; key?: string } = { + type: authType, + }; + if (authType === "header" && options.authKey) { + authMechanism.key = options.authKey; + } + + const config = await client.gatewayConfigs.create({ + name: options.name, + endpoint: options.endpoint, + auth_mechanism: authMechanism, + description: options.description, + }); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(config.id); + } else { + output(config, { format: options.output, defaultFormat: "json" }); + } + } catch (error) { + outputError("Failed to create gateway config", error); + } +} diff --git a/src/commands/gateway-config/delete.ts b/src/commands/gateway-config/delete.ts new file mode 100644 index 00000000..bd221a0a --- /dev/null +++ b/src/commands/gateway-config/delete.ts @@ -0,0 +1,33 @@ +/** + * Delete gateway config command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface DeleteOptions { + output?: string; +} + +export async function deleteGatewayConfig( + id: string, + options: DeleteOptions = {}, +) { + try { + const client = getClient(); + + await client.gatewayConfigs.delete(id); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(id); + } else { + output( + { id, status: "deleted" }, + { format: options.output, defaultFormat: "json" }, + ); + } + } catch (error) { + outputError("Failed to delete gateway config", error); + } +} diff --git a/src/commands/gateway-config/get.ts b/src/commands/gateway-config/get.ts new file mode 100644 index 00000000..d7e017d4 --- /dev/null +++ b/src/commands/gateway-config/get.ts @@ -0,0 +1,23 @@ +/** + * Get gateway config command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface GetOptions { + id: string; + output?: string; +} + +export async function getGatewayConfig(options: GetOptions) { + try { + const client = getClient(); + + const config = await client.gatewayConfigs.retrieve(options.id); + + output(config, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to get gateway config", error); + } +} diff --git a/src/commands/gateway-config/list.tsx b/src/commands/gateway-config/list.tsx new file mode 100644 index 00000000..b7527e51 --- /dev/null +++ b/src/commands/gateway-config/list.tsx @@ -0,0 +1,763 @@ +import React from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import figures from "figures"; +import type { GatewayConfigsCursorIDPage } from "@runloop/api-client/pagination"; +import { getClient } from "../../utils/client.js"; +import { Header } from "../../components/Header.js"; +import { SpinnerComponent } from "../../components/Spinner.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SuccessMessage } from "../../components/SuccessMessage.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; +import { Table, createTextColumn } from "../../components/Table.js"; +import { ActionsPopup } from "../../components/ActionsPopup.js"; +import { Operation } from "../../components/OperationsMenu.js"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { SearchBar } from "../../components/SearchBar.js"; +import { output, outputError } from "../../utils/output.js"; +import { colors } from "../../utils/theme.js"; +import { useViewportHeight } from "../../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; +import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useListSearch } from "../../hooks/useListSearch.js"; +import { useNavigation } from "../../store/navigationStore.js"; +import { GatewayConfigCreatePage } from "../../components/GatewayConfigCreatePage.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; + +interface ListOptions { + name?: string; + output?: string; +} + +// Local interface for gateway config data used in this component +interface GatewayConfigListItem { + id: string; + name: string; + description?: string; + endpoint: string; + create_time_ms: number; + auth_mechanism: { + type: string; + key?: string | null; + }; + account_id?: string | null; +} + +const DEFAULT_PAGE_SIZE = 10; + +/** + * Get a display label for the auth mechanism type + */ +function getAuthTypeLabel( + authMechanism: GatewayConfigListItem["auth_mechanism"], +): string { + if (authMechanism.type === "bearer") { + return "Bearer"; + } + if (authMechanism.type === "header") { + return authMechanism.key ? `Header: ${authMechanism.key}` : "Header"; + } + return authMechanism.type; +} + +const ListGatewayConfigsUI = ({ + onBack, + onExit, +}: { + onBack?: () => void; + onExit?: () => void; +}) => { + const { exit: inkExit } = useApp(); + const { navigate } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [showPopup, setShowPopup] = React.useState(false); + const [selectedOperation, setSelectedOperation] = React.useState(0); + const [selectedConfig, setSelectedConfig] = + React.useState(null); + const [executingOperation, setExecutingOperation] = React.useState< + string | null + >(null); + const [operationResult, setOperationResult] = React.useState( + null, + ); + const [operationError, setOperationError] = React.useState( + null, + ); + const [operationLoading, setOperationLoading] = React.useState(false); + const [showCreateConfig, setShowCreateConfig] = React.useState(false); + const [showEditConfig, setShowEditConfig] = React.useState(false); + const [editingConfig, setEditingConfig] = + React.useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + + // Search state + const search = useListSearch({ + onSearchSubmit: () => setSelectedIndex(0), + onSearchClear: () => setSelectedIndex(0), + }); + + // Calculate overhead for viewport height + const overhead = 13 + search.getSearchOverhead(); + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // All width constants + const fixedWidth = 6; // border + padding + const idWidth = 25; + const authWidth = 15; + const timeWidth = 20; + const showEndpoint = terminalWidth >= 100; + const endpointWidth = Math.max(20, terminalWidth >= 140 ? 40 : 25); + + // Name width uses remaining space after fixed columns + const baseWidth = fixedWidth + idWidth + authWidth + timeWidth; + const optionalWidth = showEndpoint ? endpointWidth : 0; + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min(80, Math.max(15, remainingWidth)); + + // Fetch function for pagination hook + const fetchPage = React.useCallback( + async (params: { limit: number; startingAt?: string }) => { + const client = getClient(); + const pageConfigs: GatewayConfigListItem[] = []; + + // Build query params + const queryParams: Record = { + limit: params.limit, + }; + if (params.startingAt) { + queryParams.starting_after = params.startingAt; + } + if (search.submittedSearchQuery) { + queryParams.name = search.submittedSearchQuery; + } + + // Fetch ONE page only + const page = (await client.gatewayConfigs.list( + queryParams, + )) as unknown as GatewayConfigsCursorIDPage; + + // Extract data and create defensive copies + if (page.gateway_configs && Array.isArray(page.gateway_configs)) { + page.gateway_configs.forEach((g: GatewayConfigListItem) => { + pageConfigs.push({ + id: g.id, + name: g.name, + description: g.description, + endpoint: g.endpoint, + create_time_ms: g.create_time_ms, + auth_mechanism: { + type: g.auth_mechanism.type, + key: g.auth_mechanism.key, + }, + account_id: g.account_id, + }); + }); + } + + const result = { + items: pageConfigs, + hasMore: page.has_more || false, + totalCount: page.total_count || pageConfigs.length, + }; + + return result; + }, + [search.submittedSearchQuery], + ); + + // Use the shared pagination hook + const { + items: configs, + loading, + navigating, + error, + currentPage, + hasMore, + hasPrev, + totalCount, + nextPage, + prevPage, + refresh, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (config: GatewayConfigListItem) => config.id, + pollInterval: 5000, + pollingEnabled: + !showPopup && + !executingOperation && + !showCreateConfig && + !showEditConfig && + !showDeleteConfirm && + !search.searchMode, + deps: [PAGE_SIZE, search.submittedSearchQuery], + }); + + // Operations for a specific gateway config (shown in popup) + const operations: Operation[] = React.useMemo( + () => [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, + { + key: "edit", + label: "Edit Gateway Config", + color: colors.warning, + icon: figures.pointer, + }, + { + key: "delete", + label: "Delete Gateway Config", + color: colors.error, + icon: figures.cross, + }, + ], + [], + ); + + // Build columns + const columns = React.useMemo( + () => [ + createTextColumn( + "id", + "ID", + (config: GatewayConfigListItem) => config.id, + { + width: idWidth + 1, + color: colors.idColor, + dimColor: false, + bold: false, + }, + ), + createTextColumn( + "name", + "Name", + (config: GatewayConfigListItem) => config.name || "", + { + width: nameWidth, + }, + ), + ...(showEndpoint + ? [ + createTextColumn( + "endpoint", + "Endpoint", + (config: GatewayConfigListItem) => config.endpoint || "", + { + width: endpointWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ] + : []), + createTextColumn( + "auth", + "Auth", + (config: GatewayConfigListItem) => + getAuthTypeLabel(config.auth_mechanism), + { + width: authWidth, + color: colors.info, + dimColor: false, + bold: false, + }, + ), + createTextColumn( + "created", + "Created", + (config: GatewayConfigListItem) => + config.create_time_ms ? formatTimeAgo(config.create_time_ms) : "", + { + width: timeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ], + [idWidth, nameWidth, endpointWidth, authWidth, timeWidth, showEndpoint], + ); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Ensure selected index is within bounds + React.useEffect(() => { + if (configs.length > 0 && selectedIndex >= configs.length) { + setSelectedIndex(Math.max(0, configs.length - 1)); + } + }, [configs.length, selectedIndex]); + + const selectedConfigItem = configs[selectedIndex]; + + // Calculate pagination info for display + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const startIndex = currentPage * PAGE_SIZE; + const endIndex = startIndex + configs.length; + + const executeOperation = async ( + config: GatewayConfigListItem, + operationKey: string, + ) => { + const client = getClient(); + + if (!config) return; + + try { + setOperationLoading(true); + switch (operationKey) { + case "delete": + await client.gatewayConfigs.delete(config.id); + setOperationResult( + `Gateway config "${config.name}" deleted successfully`, + ); + break; + } + } catch (err) { + setOperationError(err as Error); + } finally { + setOperationLoading(false); + } + }; + + useInput((input, key) => { + // Handle search mode input + if (search.searchMode) { + if (key.escape) { + search.cancelSearch(); + } + return; + } + + // Handle operation result display + if (operationResult || operationError) { + if (input === "q" || key.escape || key.return) { + const wasDelete = executingOperation === "delete"; + const hadError = operationError !== null; + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setSelectedConfig(null); + // Refresh the list after delete to show updated data + if (wasDelete && !hadError) { + setTimeout(() => refresh(), 0); + } + } + return; + } + + // Handle create config screen + if (showCreateConfig) { + return; + } + + // Handle edit config screen + if (showEditConfig) { + return; + } + + // Handle popup navigation + if (showPopup) { + if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + setShowPopup(false); + const operationKey = operations[selectedOperation].key; + + if (operationKey === "create") { + setShowCreateConfig(true); + } else if (operationKey === "view_details") { + navigate("gateway-config-detail", { + gatewayConfigId: selectedConfigItem.id, + }); + } else if (operationKey === "edit") { + // Show edit form + setEditingConfig(selectedConfigItem); + setShowEditConfig(true); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedConfig(selectedConfigItem); + setShowDeleteConfirm(true); + } else { + setSelectedConfig(selectedConfigItem); + setExecutingOperation(operationKey); + // Execute immediately with values passed directly + executeOperation(selectedConfigItem, operationKey); + } + } else if (input === "c") { + // Create hotkey + setShowPopup(false); + setShowCreateConfig(true); + } else if (input === "v" && selectedConfigItem) { + // View details hotkey + setShowPopup(false); + navigate("gateway-config-detail", { + gatewayConfigId: selectedConfigItem.id, + }); + } else if (input === "e" && selectedConfigItem) { + // Edit hotkey + setShowPopup(false); + setEditingConfig(selectedConfigItem); + setShowEditConfig(true); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } else if (input === "d") { + // Delete hotkey - show confirmation + setShowPopup(false); + setSelectedConfig(selectedConfigItem); + setShowDeleteConfirm(true); + } + return; + } + + const pageConfigs = configs.length; + + // Handle list view navigation + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < pageConfigs - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ( + (input === "n" || key.rightArrow) && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "p" || key.leftArrow) && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(0); + } else if (key.return && selectedConfigItem) { + // Enter key navigates to detail view + navigate("gateway-config-detail", { + gatewayConfigId: selectedConfigItem.id, + }); + } else if (input === "a") { + setShowPopup(true); + setSelectedOperation(0); + } else if (input === "c") { + // Create shortcut + setShowCreateConfig(true); + } else if (input === "e" && selectedConfigItem) { + // Edit shortcut + setEditingConfig(selectedConfigItem); + setShowEditConfig(true); + } else if (input === "/") { + search.enterSearchMode(); + } else if (key.escape) { + if (search.handleEscape()) { + return; + } + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // Delete confirmation + if (showDeleteConfirm && selectedConfig) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedConfig, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedConfig(null); + }} + /> + ); + } + + // Operation result display + if (operationResult || operationError) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + return ( + <> + +
+ {operationResult && } + {operationError && ( + + )} + + + ); + } + + // Operation loading state + if (operationLoading && selectedConfig) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + const messages: Record = { + delete: "Deleting gateway config...", + }; + return ( + <> + +
+ + + ); + } + + // Create config screen + if (showCreateConfig) { + return ( + setShowCreateConfig(false)} + onCreate={(config) => { + setShowCreateConfig(false); + navigate("gateway-config-detail", { gatewayConfigId: config.id }); + }} + /> + ); + } + + // Edit config screen + if (showEditConfig && editingConfig) { + return ( + { + setShowEditConfig(false); + setEditingConfig(null); + }} + onCreate={() => { + setShowEditConfig(false); + setEditingConfig(null); + // Refresh the list to show updated data + setTimeout(() => refresh(), 0); + }} + initialConfig={editingConfig} + /> + ); + } + + // Loading state + if (loading && configs.length === 0) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Main list view + return ( + <> + + + {/* Search bar */} + + + {/* Table - hide when popup is shown */} + {!showPopup && ( + config.id} + selectedIndex={selectedIndex} + title={`gateway_configs[${totalCount}]`} + columns={columns} + emptyState={ + + {figures.info} No gateway configs found. Press [c] to create one. + + } + /> + )} + + {/* Statistics Bar - hide when popup is shown */} + {!showPopup && ( + + + {figures.hamburger} {totalCount} + + + {" "} + total + + {totalPages > 1 && ( + <> + + {" "} + •{" "} + + {navigating ? ( + + {figures.pointer} Loading page {currentPage + 1}... + + ) : ( + + Page {currentPage + 1} of {totalPages} + + )} + + )} + + {" "} + •{" "} + + + Showing {startIndex + 1}-{endIndex} of {totalCount} + + {search.submittedSearchQuery && ( + <> + + {" "} + •{" "} + + + Filtered: "{search.submittedSearchQuery}" + + + )} + + )} + + {/* Actions Popup */} + {showPopup && selectedConfigItem && ( + + ({ + key: op.key, + label: op.label, + color: op.color, + icon: op.icon, + shortcut: + op.key === "create" + ? "c" + : op.key === "view_details" + ? "v" + : op.key === "edit" + ? "e" + : op.key === "delete" + ? "d" + : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + {/* Help Bar */} + + + ); +}; + +// Export the UI component for use in the main menu +export { ListGatewayConfigsUI }; + +export async function listGatewayConfigs(options: ListOptions = {}) { + try { + const client = getClient(); + + // Build query params + const queryParams: Record = { + limit: DEFAULT_PAGE_SIZE, + }; + if (options.name) { + queryParams.name = options.name; + } + + // Fetch gateway configs + const page = (await client.gatewayConfigs.list( + queryParams, + )) as GatewayConfigsCursorIDPage<{ id: string }>; + + // Extract gateway configs array + const gatewayConfigs = page.gateway_configs || []; + + output(gatewayConfigs, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list gateway configs", error); + } +} diff --git a/src/commands/gateway-config/update.ts b/src/commands/gateway-config/update.ts new file mode 100644 index 00000000..b65d0295 --- /dev/null +++ b/src/commands/gateway-config/update.ts @@ -0,0 +1,81 @@ +/** + * Update gateway config command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface UpdateOptions { + id: string; + name?: string; + endpoint?: string; + authType?: string; + authKey?: string; + description?: string; + output?: string; +} + +export async function updateGatewayConfig(options: UpdateOptions) { + try { + const client = getClient(); + + // Build update params - only include fields that are provided + const updateParams: Record = {}; + + if (options.name) { + updateParams.name = options.name; + } + if (options.endpoint) { + updateParams.endpoint = options.endpoint; + } + if (options.description !== undefined) { + updateParams.description = options.description; + } + + // Handle auth mechanism update + if (options.authType) { + const authType = options.authType.toLowerCase(); + if (authType !== "bearer" && authType !== "header") { + outputError("Invalid auth type. Must be 'bearer' or 'header'"); + return; + } + + const authMechanism: { type: string; key?: string } = { + type: authType, + }; + if (authType === "header") { + if (!options.authKey) { + outputError("--auth-key is required when auth-type is 'header'"); + return; + } + authMechanism.key = options.authKey; + } + updateParams.auth_mechanism = authMechanism; + } else if (options.authKey) { + // If only auth key is provided without auth type, we need the type + outputError("--auth-type is required when updating --auth-key"); + return; + } + + if (Object.keys(updateParams).length === 0) { + outputError( + "No update options provided. Use --name, --endpoint, --auth-type, --auth-key, or --description", + ); + return; + } + + const config = await client.gatewayConfigs.update( + options.id, + updateParams as Parameters[1], + ); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(config.id); + } else { + output(config, { format: options.output, defaultFormat: "json" }); + } + } catch (error) { + outputError("Failed to update gateway config", error); + } +} diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 87cb10bd..964cb8ed 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -28,9 +28,18 @@ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { listBlueprints } from "../services/blueprintService.js"; import { listSnapshots } from "../services/snapshotService.js"; import { listNetworkPolicies } from "../services/networkPolicyService.js"; +import { listGatewayConfigs } from "../services/gatewayConfigService.js"; import type { Blueprint } from "../store/blueprintStore.js"; import type { Snapshot } from "../store/snapshotStore.js"; import type { NetworkPolicy } from "../store/networkPolicyStore.js"; +import type { GatewayConfig } from "../store/gatewayConfigStore.js"; + +// Secret list interface for the picker +interface SecretListItem { + id: string; + name: string; + create_time_ms?: number; +} interface DevboxCreatePageProps { onBack: () => void; @@ -50,7 +59,17 @@ type FormField = | "keep_alive" | "metadata" | "source" - | "network_policy_id"; + | "network_policy_id" + | "gateways"; + +// Gateway configuration for devbox +interface GatewaySpec { + envPrefix: string; + gateway: string; // gateway config ID or name + gatewayName: string; // display name + secret: string; // secret ID or name + secretName: string; // display name +} const sourceTypes = ["blueprint", "snapshot"] as const; type SourceTypeToggle = (typeof sourceTypes)[number]; @@ -75,6 +94,7 @@ interface FormData { blueprint_id: string; snapshot_id: string; network_policy_id: string; + gateways: GatewaySpec[]; } const architectures = ["arm64", "x86_64"] as const; @@ -107,6 +127,7 @@ export const DevboxCreatePage = ({ blueprint_id: initialBlueprintId || "", snapshot_id: initialSnapshotId || "", network_policy_id: "", + gateways: [], }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -134,6 +155,20 @@ export const DevboxCreatePage = ({ const [selectedNetworkPolicyName, setSelectedNetworkPolicyName] = React.useState(""); + // Gateway picker states + const [showGatewayPicker, setShowGatewayPicker] = React.useState(false); + const [showSecretPicker, setShowSecretPicker] = React.useState(false); + const [inGatewaySection, setInGatewaySection] = React.useState(false); + const [gatewayEnvPrefix, setGatewayEnvPrefix] = React.useState(""); + const [gatewayInputMode, setGatewayInputMode] = React.useState< + "envPrefix" | "gateway" | "secret" | null + >(null); + const [selectedGatewayIndex, setSelectedGatewayIndex] = React.useState(0); + const [pendingGateway, setPendingGateway] = React.useState<{ + id: string; + name: string; + } | null>(null); + const baseFields: Array<{ key: FormField; label: string; @@ -179,7 +214,14 @@ export const DevboxCreatePage = ({ const remainingFields: Array<{ key: FormField; label: string; - type: "text" | "select" | "metadata" | "action" | "picker" | "source"; + type: + | "text" + | "select" + | "metadata" + | "action" + | "picker" + | "source" + | "gateways"; placeholder?: string; }> = [ { @@ -200,6 +242,12 @@ export const DevboxCreatePage = ({ type: "picker", placeholder: "Select a network policy...", }, + { + key: "gateways", + label: "Gateways (optional)", + type: "gateways", + placeholder: "Configure API credential proxying...", + }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -283,6 +331,13 @@ export const DevboxCreatePage = ({ return; } + // Enter key on gateways field to enter gateway section + if (currentField === "gateways" && key.return) { + setInGatewaySection(true); + setSelectedGatewayIndex(0); + return; + } + // Enter key on source field to open the appropriate picker if (currentField === "source" && key.return) { // If something is already selected, open that type's picker to change it @@ -342,9 +397,12 @@ export const DevboxCreatePage = ({ { isActive: !inMetadataSection && + !inGatewaySection && !showBlueprintPicker && !showSnapshotPicker && - !showNetworkPolicyPicker, + !showNetworkPolicyPicker && + !showGatewayPicker && + !showSecretPicker, }, ); @@ -391,6 +449,45 @@ export const DevboxCreatePage = ({ [], ); + // Handle gateway config selection + const handleGatewaySelect = React.useCallback((configs: GatewayConfig[]) => { + if (configs.length > 0) { + const config = configs[0]; + setPendingGateway({ id: config.id, name: config.name || config.id }); + setShowGatewayPicker(false); + // Now show secret picker + setShowSecretPicker(true); + } else { + setShowGatewayPicker(false); + } + }, []); + + // Handle secret selection for gateway + const handleSecretSelect = React.useCallback( + (secrets: SecretListItem[]) => { + if (secrets.length > 0 && pendingGateway && gatewayEnvPrefix) { + const secret = secrets[0]; + const newGateway: GatewaySpec = { + envPrefix: gatewayEnvPrefix, + gateway: pendingGateway.id, + gatewayName: pendingGateway.name, + secret: secret.id, + secretName: secret.name || secret.id, + }; + setFormData((prev) => ({ + ...prev, + gateways: [...prev.gateways, newGateway], + })); + } + setShowSecretPicker(false); + setPendingGateway(null); + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + setSelectedGatewayIndex(0); + }, + [pendingGateway, gatewayEnvPrefix], + ); + // Handle clearing source const handleClearSource = React.useCallback(() => { setFormData((prev) => ({ ...prev, blueprint_id: "", snapshot_id: "" })); @@ -488,6 +585,68 @@ export const DevboxCreatePage = ({ { isActive: inMetadataSection }, ); + // Gateway section input handler - active when in gateway section + useInput( + (input, key) => { + const gatewayCount = formData.gateways.length; + const maxIndex = gatewayCount + 1; // Add new + existing items + Done + + // Handle input mode (typing env prefix) + if (gatewayInputMode === "envPrefix") { + if (key.return && gatewayEnvPrefix.trim()) { + // Open gateway picker + setGatewayInputMode(null); + setShowGatewayPicker(true); + return; + } else if (key.escape) { + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + return; + } + return; + } + + // Navigation mode in gateway section + if (key.upArrow && selectedGatewayIndex > 0) { + setSelectedGatewayIndex(selectedGatewayIndex - 1); + } else if (key.downArrow && selectedGatewayIndex < maxIndex) { + setSelectedGatewayIndex(selectedGatewayIndex + 1); + } else if (key.return) { + if (selectedGatewayIndex === 0) { + // Add new gateway - start with env prefix input + setGatewayEnvPrefix(""); + setGatewayInputMode("envPrefix"); + } else if (selectedGatewayIndex === maxIndex) { + // Done + setInGatewaySection(false); + setSelectedGatewayIndex(0); + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + } + } else if ( + (input === "d" || key.delete) && + selectedGatewayIndex >= 1 && + selectedGatewayIndex <= gatewayCount + ) { + // Delete gateway at index + const indexToDelete = selectedGatewayIndex - 1; + const newGateways = [...formData.gateways]; + newGateways.splice(indexToDelete, 1); + setFormData({ ...formData, gateways: newGateways }); + const newLength = newGateways.length; + if (selectedGatewayIndex > newLength) { + setSelectedGatewayIndex(Math.max(0, newLength)); + } + } else if (key.escape || input === "q") { + setInGatewaySection(false); + setSelectedGatewayIndex(0); + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + } + }, + { isActive: inGatewaySection && !showGatewayPicker && !showSecretPicker }, + ); + // Validate custom resource configuration const validateCustomResources = (): string | null => { if (formData.resource_size !== "CUSTOM_SIZE") { @@ -595,6 +754,19 @@ export const DevboxCreatePage = ({ createParams.launch_parameters = launchParameters; } + // Add gateway specifications + if (formData.gateways.length > 0) { + const gateways: Record = + {}; + for (const gw of formData.gateways) { + gateways[gw.envPrefix] = { + gateway: gw.gateway, + secret: gw.secret, + }; + } + createParams.gateways = gateways; + } + const devbox = await client.devboxes.create(createParams); setResult(devbox); } catch (err) { @@ -904,6 +1076,142 @@ export const DevboxCreatePage = ({ ); } + // Gateway config picker screen + if (showGatewayPicker) { + const gatewayColumns: Column[] = [ + createTextColumn("id", "ID", (config) => config.id, { + width: 25, + color: colors.idColor, + }), + createTextColumn( + "name", + "Name", + (config) => config.name || "", + { width: 25 }, + ), + createTextColumn( + "endpoint", + "Endpoint", + (config) => config.endpoint || "", + { width: 30, color: colors.textDim }, + ), + createTextColumn( + "created", + "Created", + (config) => + config.create_time_ms ? formatTimeAgo(config.create_time_ms) : "", + { width: 18, color: colors.textDim }, + ), + ]; + + return ( + + config={{ + title: "Select Gateway Config", + fetchPage: async (params) => { + const result = await listGatewayConfigs({ + limit: params.limit, + startingAfter: params.startingAt, + search: params.search, + }); + return { + items: result.gatewayConfigs, + hasMore: result.hasMore, + totalCount: result.totalCount, + }; + }, + getItemId: (config) => config.id, + getItemLabel: (config) => config.name || config.id, + columns: gatewayColumns, + mode: "single", + emptyMessage: "No gateway configs found", + searchPlaceholder: "Search gateway configs...", + breadcrumbItems: [ + { label: "Devboxes" }, + { label: "Create" }, + { label: `Gateway: ${gatewayEnvPrefix}`, active: true }, + ], + }} + onSelect={handleGatewaySelect} + onCancel={() => { + setShowGatewayPicker(false); + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + }} + initialSelected={[]} + /> + ); + } + + // Secret picker screen (for gateway) + if (showSecretPicker) { + const secretColumns: Column[] = [ + createTextColumn("id", "ID", (secret) => secret.id, { + width: 25, + color: colors.idColor, + }), + createTextColumn( + "name", + "Name", + (secret) => secret.name || "", + { width: 30 }, + ), + createTextColumn( + "created", + "Created", + (secret) => + secret.create_time_ms ? formatTimeAgo(secret.create_time_ms) : "", + { width: 18, color: colors.textDim }, + ), + ]; + + return ( + + config={{ + title: "Select Secret for Gateway", + fetchPage: async (params) => { + const client = getClient(); + // Secrets API doesn't support cursor pagination, just limit + const page = await client.secrets.list({ + limit: params.limit, + }); + return { + items: (page.secrets || []).map( + (s: { id: string; name: string; create_time_ms?: number }) => ({ + id: s.id, + name: s.name, + create_time_ms: s.create_time_ms, + }), + ), + hasMore: false, // Secrets API doesn't support pagination + totalCount: page.total_count || 0, + }; + }, + getItemId: (secret) => secret.id, + getItemLabel: (secret) => secret.name || secret.id, + columns: secretColumns, + mode: "single", + emptyMessage: "No secrets found", + searchPlaceholder: "Search secrets...", + breadcrumbItems: [ + { label: "Devboxes" }, + { label: "Create" }, + { label: `Gateway: ${gatewayEnvPrefix}` }, + { label: "Select Secret", active: true }, + ], + }} + onSelect={handleSecretSelect} + onCancel={() => { + setShowSecretPicker(false); + setPendingGateway(null); + setGatewayEnvPrefix(""); + setGatewayInputMode(null); + }} + initialSelected={[]} + /> + ); + } + // Form screen return ( <> @@ -1277,6 +1585,192 @@ export const DevboxCreatePage = ({ ); } + if (field.type === "gateways") { + if (!inGatewaySection) { + // Collapsed view + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + + {formData.gateways.length} gateway(s) + + {isActive && ( + + {" "} + [Enter to manage] + + )} + + {formData.gateways.length > 0 && ( + + {formData.gateways.map((gw, idx) => ( + + {figures.pointer} {gw.envPrefix}: {gw.gatewayName} →{" "} + {gw.secretName} + + ))} + + )} + + ); + } + + // Expanded gateway section view + const gatewayCount = formData.gateways.length; + const maxGatewayIndex = gatewayCount + 1; + + return ( + + + {figures.hamburger} Manage Gateway Configurations + + + {/* Input form - shown when adding */} + {gatewayInputMode === "envPrefix" && ( + + + Adding New Gateway + + + + Env Prefix (e.g., GWS_ANTHROPIC):{" "} + + + + + Press Enter to select gateway config + + + )} + + {/* Navigation menu - shown when not in input mode */} + {!gatewayInputMode && ( + <> + {/* Add new option */} + + + {selectedGatewayIndex === 0 + ? figures.pointer + : " "}{" "} + + + + Add new gateway + + + + {/* Existing items */} + {gatewayCount > 0 && ( + + {formData.gateways.map((gw, index) => { + const itemIndex = index + 1; + const isGatewaySelected = + selectedGatewayIndex === itemIndex; + return ( + + + {isGatewaySelected ? figures.pointer : " "}{" "} + + + {gw.envPrefix}: {gw.gatewayName} →{" "} + {gw.secretName} + + + ); + })} + + )} + + {/* Done option */} + + + {selectedGatewayIndex === maxGatewayIndex + ? figures.pointer + : " "}{" "} + + + {figures.tick} Done + + + + )} + + {/* Help text */} + + + {gatewayInputMode + ? `[Enter] Select gateway • [esc] Cancel` + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedGatewayIndex === 0 ? "Add" : selectedGatewayIndex === maxGatewayIndex ? "Done" : "Select"} • [d] Delete • [esc] Back`} + + + + ); + } + return null; })} @@ -1300,7 +1794,7 @@ export const DevboxCreatePage = ({ )} - {!inMetadataSection && ( + {!inMetadataSection && !inGatewaySection && ( void; + onCreate?: (config: GatewayConfigView) => void; + initialConfig?: { + id?: string; + name: string; + endpoint: string; + description?: string | null; + auth_mechanism: { + type: string; + key?: string | null; + }; + }; +} + +type FormField = + | "create" + | "name" + | "endpoint" + | "auth_type" + | "auth_key" + | "description"; + +const authTypes = ["bearer", "header"] as const; +type AuthType = (typeof authTypes)[number]; + +interface FormData { + name: string; + endpoint: string; + auth_type: AuthType; + auth_key: string; + description: string; +} + +export const GatewayConfigCreatePage = ({ + onBack, + onCreate, + initialConfig, +}: GatewayConfigCreatePageProps) => { + const isEditing = !!initialConfig?.id; + + const [currentField, setCurrentField] = React.useState("create"); + + // Normalize auth type from API to match our options (lowercase) + const normalizeAuthType = (type: string | undefined): AuthType => { + const normalized = (type || "").toLowerCase(); + if (normalized === "header" || normalized === "bearer") { + return normalized; + } + return "bearer"; // default + }; + + const [formData, setFormData] = React.useState({ + name: initialConfig?.name || "", + endpoint: initialConfig?.endpoint || "", + auth_type: normalizeAuthType(initialConfig?.auth_mechanism?.type), + auth_key: initialConfig?.auth_mechanism?.key || "", + description: initialConfig?.description || "", + }); + const [creating, setCreating] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + + const fields: Array<{ + key: FormField; + label: string; + type: "text" | "select" | "action"; + placeholder?: string; + }> = [ + { + key: "create", + label: isEditing ? "Update Gateway Config" : "Create Gateway Config", + type: "action", + }, + { key: "name", label: "Name", type: "text", placeholder: "my-gateway" }, + { + key: "endpoint", + label: "Endpoint URL", + type: "text", + placeholder: "https://api.example.com", + }, + { key: "auth_type", label: "Auth Type", type: "select" }, + { + key: "auth_key", + label: "Auth Header Key (for header type)", + type: "text", + placeholder: "x-api-key", + }, + { + key: "description", + label: "Description (optional)", + type: "text", + placeholder: "Gateway for...", + }, + ]; + + const currentFieldIndex = fields.findIndex((f) => f.key === currentField); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Select navigation handlers using shared hook + const handleAuthTypeNav = useFormSelectNavigation( + formData.auth_type, + authTypes, + (value) => { + setFormData({ + ...formData, + auth_type: value, + // Clear auth_key if switching from header to bearer + auth_key: value !== "header" ? "" : formData.auth_key, + }); + // If switching away from header and currently on auth_key field, move to next field + if (value !== "header" && currentField === "auth_key") { + setCurrentField("description"); + } + }, + currentField === "auth_type", + ); + + // Main form input handler + useInput( + (input, key) => { + // Handle result screen + if (result) { + if (input === "q" || key.escape || key.return) { + if (onCreate) { + onCreate(result); + } else { + onBack(); + } + } + return; + } + + // Handle error screen + if (error) { + if (input === "r" || key.return) { + // Retry - clear error and return to form + setError(null); + } else if (input === "q" || key.escape) { + // Quit - go back to list + onBack(); + } + return; + } + + // Handle creating state + if (creating) { + return; + } + + // Back to list + if (input === "q" || key.escape) { + onBack(); + return; + } + + // Submit form with Ctrl+S + if (input === "s" && key.ctrl) { + handleCreate(); + return; + } + + // Handle Enter on any field to submit + if (key.return) { + handleCreate(); + return; + } + + // Handle select field navigation using shared hooks + if (handleAuthTypeNav(input, key)) return; + + // Navigation (up/down arrows and tab/shift+tab) + // Skip auth_key field if auth_type is not "header" + const getNextField = (direction: "up" | "down"): FormField | null => { + let nextIndex = + direction === "up" ? currentFieldIndex - 1 : currentFieldIndex + 1; + + while (nextIndex >= 0 && nextIndex < fields.length) { + const nextField = fields[nextIndex].key; + // Skip auth_key if auth_type is not header + if (nextField === "auth_key" && formData.auth_type !== "header") { + nextIndex = direction === "up" ? nextIndex - 1 : nextIndex + 1; + continue; + } + return nextField; + } + return null; + }; + + if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) { + const nextField = getNextField("up"); + if (nextField) { + setCurrentField(nextField); + } + return; + } + + if ( + (key.downArrow || (key.tab && !key.shift)) && + currentFieldIndex < fields.length - 1 + ) { + const nextField = getNextField("down"); + if (nextField) { + setCurrentField(nextField); + } + return; + } + }, + { isActive: true }, + ); + + const handleCreate = async () => { + // Validate required fields + if (!formData.name.trim()) { + setError(new Error("Name is required")); + return; + } + if (!formData.endpoint.trim()) { + setError(new Error("Endpoint URL is required")); + return; + } + if (formData.auth_type === "header" && !formData.auth_key.trim()) { + setError(new Error("Auth header key is required for header auth type")); + return; + } + + setCreating(true); + setError(null); + + try { + const client = getClient(); + + const authMechanism: { type: string; key?: string } = { + type: formData.auth_type, + }; + if (formData.auth_type === "header" && formData.auth_key.trim()) { + authMechanism.key = formData.auth_key.trim(); + } + + let config: GatewayConfigView; + + if (isEditing && initialConfig?.id) { + // Update existing config + config = await client.gatewayConfigs.update(initialConfig.id, { + name: formData.name.trim(), + endpoint: formData.endpoint.trim(), + auth_mechanism: authMechanism, + description: formData.description.trim() || undefined, + }); + } else { + // Create new config + config = await client.gatewayConfigs.create({ + name: formData.name.trim(), + endpoint: formData.endpoint.trim(), + auth_mechanism: authMechanism, + description: formData.description.trim() || undefined, + }); + } + + setResult(config); + } catch (err) { + setError(err as Error); + } finally { + setCreating(false); + } + }; + + // Result screen + if (result) { + return ( + <> + + + + + + ID:{" "} + + {result.id} + + + + Name: {result.name || "(none)"} + + + + + Endpoint: {result.endpoint} + + + + + + ); + } + + // Error screen + if (error) { + return ( + <> + + + + + ); + } + + // Creating screen + if (creating) { + return ( + <> + + + + ); + } + + // Form screen + return ( + <> + + + + {fields.map((field) => { + const isActive = currentField === field.key; + const fieldData = formData[field.key as keyof FormData]; + + if (field.type === "action") { + return ( + + ); + } + + if (field.type === "text") { + // Skip auth_key field if auth type is bearer + if (field.key === "auth_key" && formData.auth_type !== "header") { + return null; + } + + return ( + + setFormData({ ...formData, [field.key]: value }) + } + onSubmit={handleCreate} + isActive={isActive} + placeholder={field.placeholder} + /> + ); + } + + if (field.type === "select") { + const value = fieldData as string; + return ( + + setFormData({ + ...formData, + [field.key]: newValue, + // Clear auth_key if switching from header to bearer + auth_key: newValue !== "header" ? "" : formData.auth_key, + }) + } + isActive={isActive} + /> + ); + } + + return null; + })} + + + + + {figures.info} Auth Types: + + + + + • bearer: Uses Bearer token authentication + + + • header: Uses custom header (specify header key) + + + + + + ); +}; diff --git a/src/components/SettingsMenu.tsx b/src/components/SettingsMenu.tsx index 5da6ea5b..556872d9 100644 --- a/src/components/SettingsMenu.tsx +++ b/src/components/SettingsMenu.tsx @@ -22,6 +22,13 @@ const settingsMenuItems: SettingsMenuItem[] = [ icon: "◇", color: colors.info, }, + { + key: "gateway-configs", + label: "Gateway Configs", + description: "Configure API credential proxying", + icon: "⬡", + color: colors.success, + }, { key: "secrets", label: "Secrets", @@ -86,7 +93,9 @@ export const SettingsMenu = ({ onSelect, onBack }: SettingsMenuProps) => { onBack(); } else if (input === "n" || input === "1") { onSelect("network-policies"); - } else if (input === "s" || input === "2") { + } else if (input === "g" || input === "2") { + onSelect("gateway-configs"); + } else if (input === "s" || input === "3") { onSelect("secrets"); } else if (input === "q") { exit(); @@ -146,7 +155,7 @@ export const SettingsMenu = ({ onSelect, onBack }: SettingsMenuProps) => { showArrows paddingX={2} tips={[ - { key: "1-2", label: "Quick select" }, + { key: "1-3", label: "Quick select" }, { key: "Enter", label: "Select" }, { key: "Esc", label: "Back" }, { key: "q", label: "Quit" }, diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 06b29cc2..6cf2f0fe 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -10,6 +10,7 @@ import { useDevboxStore } from "../store/devboxStore.js"; import { useBlueprintStore } from "../store/blueprintStore.js"; import { useSnapshotStore } from "../store/snapshotStore.js"; import { useNetworkPolicyStore } from "../store/networkPolicyStore.js"; +import { useGatewayConfigStore } from "../store/gatewayConfigStore.js"; import { useObjectStore } from "../store/objectStore.js"; import { useBenchmarkStore } from "../store/benchmarkStore.js"; import { useBenchmarkJobStore } from "../store/benchmarkJobStore.js"; @@ -34,6 +35,9 @@ const KNOWN_SCREENS: Set = new Set([ "network-policy-list", "network-policy-detail", "network-policy-create", + "gateway-config-list", + "gateway-config-detail", + "gateway-config-create", "secret-list", "secret-detail", "secret-create", @@ -105,6 +109,8 @@ import { SnapshotDetailScreen } from "../screens/SnapshotDetailScreen.js"; import { NetworkPolicyListScreen } from "../screens/NetworkPolicyListScreen.js"; import { NetworkPolicyDetailScreen } from "../screens/NetworkPolicyDetailScreen.js"; import { NetworkPolicyCreateScreen } from "../screens/NetworkPolicyCreateScreen.js"; +import { GatewayConfigListScreen } from "../screens/GatewayConfigListScreen.js"; +import { GatewayConfigDetailScreen } from "../screens/GatewayConfigDetailScreen.js"; import { SettingsMenuScreen } from "../screens/SettingsMenuScreen.js"; import { SecretListScreen } from "../screens/SecretListScreen.js"; import { SecretDetailScreen } from "../screens/SecretDetailScreen.js"; @@ -176,6 +182,14 @@ export function Router() { } break; + case "gateway-config-list": + case "gateway-config-detail": + case "gateway-config-create": + if (!currentScreen.startsWith("gateway-config")) { + useGatewayConfigStore.getState().clearAll(); + } + break; + case "object-list": case "object-detail": if (!currentScreen.startsWith("object")) { @@ -262,6 +276,12 @@ export function Router() { {currentScreen === "network-policy-create" && ( )} + {currentScreen === "gateway-config-list" && ( + + )} + {currentScreen === "gateway-config-detail" && ( + + )} {currentScreen === "secret-list" && ( )} diff --git a/src/screens/GatewayConfigDetailScreen.tsx b/src/screens/GatewayConfigDetailScreen.tsx new file mode 100644 index 00000000..7584b746 --- /dev/null +++ b/src/screens/GatewayConfigDetailScreen.tsx @@ -0,0 +1,406 @@ +/** + * GatewayConfigDetailScreen - Detail page for gateway configs + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { + useGatewayConfigStore, + type GatewayConfig, +} from "../store/gatewayConfigStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, + type ResourceOperation, +} from "../components/ResourceDetailPage.js"; +import { + getGatewayConfig, + deleteGatewayConfig, +} from "../services/gatewayConfigService.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; +import { GatewayConfigCreatePage } from "../components/GatewayConfigCreatePage.js"; +import { colors } from "../utils/theme.js"; + +interface GatewayConfigDetailScreenProps { + gatewayConfigId?: string; +} + +/** + * Get a display label for the auth mechanism type + */ +function getAuthTypeLabel( + authMechanism: GatewayConfig["auth_mechanism"], +): string { + if (authMechanism.type === "bearer") { + return "Bearer Token"; + } + if (authMechanism.type === "header") { + return authMechanism.key ? `Header: ${authMechanism.key}` : "Header"; + } + return authMechanism.type; +} + +export function GatewayConfigDetailScreen({ + gatewayConfigId, +}: GatewayConfigDetailScreenProps) { + const { goBack } = useNavigation(); + const gatewayConfigs = useGatewayConfigStore((state) => state.gatewayConfigs); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [fetchedConfig, setFetchedConfig] = + React.useState(null); + const [deleting, setDeleting] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const [showEditForm, setShowEditForm] = React.useState(false); + + // Find config in store first + const configFromStore = gatewayConfigs.find((c) => c.id === gatewayConfigId); + + // Fetch config from API if not in store or missing full details + React.useEffect(() => { + if (gatewayConfigId && !loading && !fetchedConfig) { + // Always fetch full details since store may only have basic info + setLoading(true); + setError(null); + + getGatewayConfig(gatewayConfigId) + .then((config) => { + setFetchedConfig(config); + setLoading(false); + }) + .catch((err) => { + setError(err as Error); + setLoading(false); + }); + } + }, [gatewayConfigId, loading, fetchedConfig]); + + // Use fetched config for full details, fall back to store for basic display + const config = fetchedConfig || configFromStore; + + // Show loading state while fetching or before fetch starts + if (!config && gatewayConfigId && !error) { + return ( + <> + + + + ); + } + + // Show error state if fetch failed + if (error && !config) { + return ( + <> + + + + ); + } + + // Show error if no config found + if (!config) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + // Basic details section + const basicFields = []; + if (config.description) { + basicFields.push({ + label: "Description", + value: config.description, + }); + } + basicFields.push({ + label: "Endpoint", + value: config.endpoint, + }); + if (config.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(config.create_time_ms), + }); + } + if (config.account_id) { + basicFields.push({ + label: "Account ID", + value: config.account_id, + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + // Auth mechanism section + const authFields = []; + authFields.push({ + label: "Auth Type", + value: ( + + {getAuthTypeLabel(config.auth_mechanism)} + + ), + }); + if (config.auth_mechanism.type === "header" && config.auth_mechanism.key) { + authFields.push({ + label: "Header Key", + value: config.auth_mechanism.key, + }); + } + + detailSections.push({ + title: "Authentication", + icon: figures.arrowRight, + color: colors.info, + fields: authFields, + }); + + // Operations available for gateway configs + const operations: ResourceOperation[] = [ + { + key: "edit", + label: "Edit Gateway Config", + color: colors.warning, + icon: figures.pointer, + shortcut: "e", + }, + { + key: "delete", + label: "Delete Gateway Config", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async ( + operation: string, + _resource: GatewayConfig, + ) => { + switch (operation) { + case "edit": + setShowEditForm(true); + break; + case "delete": + // Show confirmation dialog + setShowDeleteConfirm(true); + break; + } + }; + + // Execute delete after confirmation + const executeDelete = async () => { + if (!config) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + await deleteGatewayConfig(config.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + }; + + // Build detailed info lines for full details view + const buildDetailLines = (gc: GatewayConfig): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Gateway Config Details + , + ); + lines.push( + + {" "} + ID: {gc.id} + , + ); + lines.push( + + {" "} + Name: {gc.name} + , + ); + if (gc.description) { + lines.push( + + {" "} + Description: {gc.description} + , + ); + } + lines.push( + + {" "} + Endpoint: {gc.endpoint} + , + ); + if (gc.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(gc.create_time_ms).toLocaleString()} + , + ); + } + if (gc.account_id) { + lines.push( + + {" "} + Account ID: {gc.account_id} + , + ); + } + lines.push( ); + + // Auth Mechanism + lines.push( + + Authentication + , + ); + lines.push( + + {" "} + Type: {getAuthTypeLabel(gc.auth_mechanism)} + , + ); + if (gc.auth_mechanism.type === "header" && gc.auth_mechanism.key) { + lines.push( + + {" "} + Header Key: {gc.auth_mechanism.key} + , + ); + } + lines.push( ); + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(gc, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + // Show edit form + if (showEditForm && config) { + return ( + setShowEditForm(false)} + onCreate={(updatedConfig) => { + // Update the fetched config with the new data + setFetchedConfig(updatedConfig as GatewayConfig); + setShowEditForm(false); + }} + initialConfig={config} + /> + ); + } + + // Show delete confirmation + if (showDeleteConfirm && config) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + return ( + gc.name || gc.id} + getId={(gc) => gc.id} + getStatus={() => "active"} // Gateway configs don't have a status field + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + /> + ); +} diff --git a/src/screens/GatewayConfigListScreen.tsx b/src/screens/GatewayConfigListScreen.tsx new file mode 100644 index 00000000..96f902a5 --- /dev/null +++ b/src/screens/GatewayConfigListScreen.tsx @@ -0,0 +1,12 @@ +/** + * GatewayConfigListScreen - Screen wrapper for gateway config list + */ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { ListGatewayConfigsUI } from "../commands/gateway-config/list.js"; + +export function GatewayConfigListScreen() { + const { goBack } = useNavigation(); + + return ; +} diff --git a/src/screens/SettingsMenuScreen.tsx b/src/screens/SettingsMenuScreen.tsx index 49127dc7..d4a2a8cd 100644 --- a/src/screens/SettingsMenuScreen.tsx +++ b/src/screens/SettingsMenuScreen.tsx @@ -13,6 +13,9 @@ export function SettingsMenuScreen() { case "network-policies": navigate("network-policy-list"); break; + case "gateway-configs": + navigate("gateway-config-list"); + break; case "secrets": navigate("secret-list"); break; diff --git a/src/services/gatewayConfigService.ts b/src/services/gatewayConfigService.ts new file mode 100644 index 00000000..dbee1216 --- /dev/null +++ b/src/services/gatewayConfigService.ts @@ -0,0 +1,182 @@ +/** + * Gateway Config Service - Handles all gateway config API calls + */ +import { getClient } from "../utils/client.js"; +import type { GatewayConfig } from "../store/gatewayConfigStore.js"; +import type { + GatewayConfigListParams, + GatewayConfigView, +} from "@runloop/api-client/resources/gateway-configs"; +import type { GatewayConfigsCursorIDPage } from "@runloop/api-client/pagination"; + +export interface ListGatewayConfigsOptions { + limit: number; + startingAfter?: string; + search?: string; +} + +export interface ListGatewayConfigsResult { + gatewayConfigs: GatewayConfig[]; + totalCount: number; + hasMore: boolean; +} + +/** + * List gateway configs with pagination + */ +export async function listGatewayConfigs( + options: ListGatewayConfigsOptions, +): Promise { + const client = getClient(); + + const queryParams: GatewayConfigListParams = { + limit: options.limit, + }; + + if (options.startingAfter) { + queryParams.starting_after = options.startingAfter; + } + if (options.search) { + queryParams.name = options.search; + } + + const pagePromise = client.gatewayConfigs.list(queryParams); + const page = + (await pagePromise) as unknown as GatewayConfigsCursorIDPage; + + const gatewayConfigs: GatewayConfig[] = []; + + if (page.gateway_configs && Array.isArray(page.gateway_configs)) { + page.gateway_configs.forEach((g: GatewayConfigView) => { + // CRITICAL: Truncate all strings to prevent Yoga crashes + const MAX_ID_LENGTH = 100; + const MAX_NAME_LENGTH = 200; + const MAX_DESC_LENGTH = 500; + const MAX_ENDPOINT_LENGTH = 500; + + gatewayConfigs.push({ + id: String(g.id || "").substring(0, MAX_ID_LENGTH), + name: String(g.name || "").substring(0, MAX_NAME_LENGTH), + description: g.description + ? String(g.description).substring(0, MAX_DESC_LENGTH) + : undefined, + endpoint: String(g.endpoint || "").substring(0, MAX_ENDPOINT_LENGTH), + create_time_ms: g.create_time_ms, + auth_mechanism: { + type: g.auth_mechanism.type, + key: g.auth_mechanism.key ?? undefined, + }, + account_id: g.account_id ?? undefined, + }); + }); + } + + const result = { + gatewayConfigs, + totalCount: page.total_count || gatewayConfigs.length, + hasMore: page.has_more || false, + }; + + return result; +} + +/** + * Get a single gateway config by ID + */ +export async function getGatewayConfig(id: string): Promise { + const client = getClient(); + const config = await client.gatewayConfigs.retrieve(id); + + return { + id: config.id, + name: config.name, + description: config.description ?? undefined, + endpoint: config.endpoint, + create_time_ms: config.create_time_ms, + auth_mechanism: { + type: config.auth_mechanism.type, + key: config.auth_mechanism.key ?? undefined, + }, + account_id: config.account_id ?? undefined, + }; +} + +/** + * Delete a gateway config + */ +export async function deleteGatewayConfig(id: string): Promise { + const client = getClient(); + await client.gatewayConfigs.delete(id); +} + +/** + * Create a gateway config + */ +export interface CreateGatewayConfigParams { + name: string; + endpoint: string; + auth_mechanism: { + type: string; + key?: string; + }; + description?: string; +} + +export async function createGatewayConfig( + params: CreateGatewayConfigParams, +): Promise { + const client = getClient(); + const config = await client.gatewayConfigs.create({ + name: params.name, + endpoint: params.endpoint, + auth_mechanism: params.auth_mechanism, + description: params.description, + }); + + return { + id: config.id, + name: config.name, + description: config.description ?? undefined, + endpoint: config.endpoint, + create_time_ms: config.create_time_ms, + auth_mechanism: { + type: config.auth_mechanism.type, + key: config.auth_mechanism.key ?? undefined, + }, + account_id: config.account_id ?? undefined, + }; +} + +/** + * Update a gateway config + */ +export interface UpdateGatewayConfigParams { + name?: string; + endpoint?: string; + auth_mechanism?: { + type: string; + key?: string; + }; + description?: string; +} + +export async function updateGatewayConfig( + id: string, + params: UpdateGatewayConfigParams, +): Promise { + const client = getClient(); + const config = await client.gatewayConfigs.update(id, params); + + return { + id: config.id, + name: config.name, + description: config.description ?? undefined, + endpoint: config.endpoint, + create_time_ms: config.create_time_ms, + auth_mechanism: { + type: config.auth_mechanism.type, + key: config.auth_mechanism.key ?? undefined, + }, + account_id: config.account_id ?? undefined, + }; +} diff --git a/src/store/gatewayConfigStore.ts b/src/store/gatewayConfigStore.ts new file mode 100644 index 00000000..7c65625f --- /dev/null +++ b/src/store/gatewayConfigStore.ts @@ -0,0 +1,152 @@ +/** + * Gateway Config Store - Manages gateway configuration state, pagination, and caching + */ +import { create } from "zustand"; +import type { GatewayConfigView } from "@runloop/api-client/resources/gateway-configs"; + +// Re-export for compatibility with existing code +export type GatewayConfig = GatewayConfigView; +export type GatewayConfigAuthMechanism = GatewayConfigView.AuthMechanism; + +interface GatewayConfigState { + // List data + gatewayConfigs: GatewayConfig[]; + loading: boolean; + initialLoading: boolean; + error: Error | null; + + // Pagination + currentPage: number; + pageSize: number; + totalCount: number; + hasMore: boolean; + + // Caching + pageCache: Map; + lastIdCache: Map; + + // Search/filter + searchQuery: string; + + // Selection + selectedIndex: number; + + // Actions + setGatewayConfigs: (configs: GatewayConfig[]) => void; + setLoading: (loading: boolean) => void; + setInitialLoading: (loading: boolean) => void; + setError: (error: Error | null) => void; + + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + setTotalCount: (count: number) => void; + setHasMore: (hasMore: boolean) => void; + + setSearchQuery: (query: string) => void; + setSelectedIndex: (index: number) => void; + + cachePageData: (page: number, data: GatewayConfig[], lastId: string) => void; + getCachedPage: (page: number) => GatewayConfig[] | undefined; + clearCache: () => void; + clearAll: () => void; + + getSelectedGatewayConfig: () => GatewayConfig | undefined; +} + +const MAX_CACHE_SIZE = 10; + +export const useGatewayConfigStore = create((set, get) => ({ + gatewayConfigs: [], + loading: false, + initialLoading: true, + error: null, + + currentPage: 0, + pageSize: 10, + totalCount: 0, + hasMore: false, + + pageCache: new Map(), + lastIdCache: new Map(), + + searchQuery: "", + selectedIndex: 0, + + setGatewayConfigs: (configs) => set({ gatewayConfigs: configs }), + setLoading: (loading) => set({ loading }), + setInitialLoading: (loading) => set({ initialLoading: loading }), + setError: (error) => set({ error }), + + setCurrentPage: (page) => set({ currentPage: page }), + setPageSize: (size) => set({ pageSize: size }), + setTotalCount: (count) => set({ totalCount: count }), + setHasMore: (hasMore) => set({ hasMore }), + + setSearchQuery: (query) => set({ searchQuery: query }), + setSelectedIndex: (index) => set({ selectedIndex: index }), + + cachePageData: (page, data, lastId) => { + const state = get(); + const pageCache = state.pageCache; + const lastIdCache = state.lastIdCache; + + // Aggressive LRU eviction + if (pageCache.size >= MAX_CACHE_SIZE) { + const oldestKey = pageCache.keys().next().value; + if (oldestKey !== undefined) { + pageCache.delete(oldestKey); + lastIdCache.delete(oldestKey); + } + } + + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as GatewayConfig; + }); + + pageCache.set(page, plainData); + lastIdCache.set(page, lastId); + + set({}); + }, + + getCachedPage: (page) => { + return get().pageCache.get(page); + }, + + clearCache: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + pageCache: new Map(), + lastIdCache: new Map(), + }); + }, + + clearAll: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + gatewayConfigs: [], + loading: false, + initialLoading: true, + error: null, + currentPage: 0, + totalCount: 0, + hasMore: false, + pageCache: new Map(), + lastIdCache: new Map(), + searchQuery: "", + selectedIndex: 0, + }); + }, + + getSelectedGatewayConfig: () => { + const state = get(); + return state.gatewayConfigs[state.selectedIndex]; + }, +})); diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 73a07ac3..5b91ca4a 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -17,6 +17,9 @@ export type ScreenName = | "network-policy-list" | "network-policy-detail" | "network-policy-create" + | "gateway-config-list" + | "gateway-config-detail" + | "gateway-config-create" | "secret-list" | "secret-detail" | "secret-create" @@ -39,6 +42,7 @@ export interface RouteParams { blueprintName?: string; snapshotId?: string; networkPolicyId?: string; + gatewayConfigId?: string; secretId?: string; objectId?: string; operation?: string; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index ac38e56d..0230320a 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -59,6 +59,10 @@ export function createProgram(): Command { .option("--root", "Run as root") .option("--user ", "Run as this user (format: username:uid)") .option("--network-policy ", "Network policy ID to apply") + .option( + "--gateways ", + "Gateway configurations (format: ENV_PREFIX=gateway_id_or_name,secret_id_or_name)", + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -749,6 +753,99 @@ export function createProgram(): Command { await deleteSecret(name, options); }); + // Gateway config commands + const gatewayConfig = program + .command("gateway-config") + .description("Manage gateway configurations") + .alias("gwc"); + + gatewayConfig + .command("list") + .description("List gateway configurations") + .option("--name ", "Filter by name") + .option("--limit ", "Max results", "20") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (options) => { + const { listGatewayConfigs } = + await import("../commands/gateway-config/list.js"); + await listGatewayConfigs(options); + }); + + gatewayConfig + .command("create") + .description("Create a new gateway configuration") + .requiredOption("--name ", "Gateway config name (required)") + .requiredOption("--endpoint ", "Target endpoint URL (required)") + .requiredOption( + "--auth-type ", + "Authentication type: bearer or header (required)", + ) + .option( + "--auth-key ", + "Header key name (required for header auth type)", + ) + .option("--description ", "Description") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { createGatewayConfig } = + await import("../commands/gateway-config/create.js"); + await createGatewayConfig(options); + }); + + gatewayConfig + .command("get ") + .description("Get gateway configuration details") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (id, options) => { + const { getGatewayConfig } = + await import("../commands/gateway-config/get.js"); + await getGatewayConfig({ id, ...options }); + }); + + gatewayConfig + .command("update ") + .description("Update a gateway configuration") + .option("--name ", "New name") + .option("--endpoint ", "New endpoint URL") + .option("--auth-type ", "New authentication type: bearer or header") + .option( + "--auth-key ", + "New header key name (required for header auth type)", + ) + .option("--description ", "New description") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (id, options) => { + const { updateGatewayConfig } = + await import("../commands/gateway-config/update.js"); + await updateGatewayConfig({ id, ...options }); + }); + + gatewayConfig + .command("delete ") + .description("Delete a gateway configuration") + .alias("rm") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (id, options) => { + const { deleteGatewayConfig } = + await import("../commands/gateway-config/delete.js"); + await deleteGatewayConfig(id, options); + }); + // MCP server commands const mcp = program .command("mcp")