From 3b7cdbca515d0fb06be4f45ea330db7ad6af446d Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 15:17:09 -0700 Subject: [PATCH 01/25] cp dines --- src/commands/devbox/list.tsx | 73 +++++++++++++++++++++++++----------- src/utils/CommandExecutor.ts | 12 ++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 74cc5654..d98369e4 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -341,13 +341,16 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { }); }, [devboxes, searchQuery]); - const running = filteredDevboxes.filter((d) => d.status === 'running').length; - const stopped = filteredDevboxes.filter((d) => - ['stopped', 'suspended'].includes(d.status) - ).length; - // Current page is already fetched, no need to slice const currentDevboxes = filteredDevboxes; + + // Ensure selected index is within bounds after filtering + React.useEffect(() => { + if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) { + setSelectedIndex(Math.max(0, currentDevboxes.length - 1)); + } + }, [currentDevboxes.length, selectedIndex]); + const selectedDevbox = currentDevboxes[selectedIndex]; // Calculate pagination info @@ -525,16 +528,35 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { ); } - // List view - return ( - <> - {/* // */} - - {/*
*/} - {loading && } - {!loading && !error && devboxes.length === 0 && ( + // If loading or error, show that first + if (loading) { + return ( + <> + + + + ); + } + + if (error) { + return ( + <> + + + + ); + } + + if (!loading && !error && devboxes.length === 0) { + return ( + <> + {figures.info} No devboxes found. Try: @@ -542,8 +564,17 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { rln devbox create - )} - {!loading && !error && devboxes.length > 0 && ( + + ); + } + + // List view with data + return ( + <> + + {currentDevboxes && currentDevboxes.length >= 0 && ( <> {searchMode && ( @@ -561,18 +592,19 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { [Esc to cancel] )} - {searchQuery && !searchMode && ( + {!searchMode && searchQuery && ( {figures.info} Searching for: {searchQuery} - ({filteredDevboxes.length} results) [/ to edit, Esc to clear] + ({currentDevboxes.length} results) [/ to edit, Esc to clear] )} devbox.id} selectedIndex={selectedIndex} - title={`devboxes[${totalCount}]`} + title={`devboxes[${searchQuery ? currentDevboxes.length : totalCount}]`} columns={[ { key: 'statusIcon', @@ -701,7 +733,6 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { )} - {error && } ); }; diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index 8ff99843..1cee777b 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -32,9 +32,13 @@ export class CommandExecutor { } // Interactive mode + // Enter alternate screen buffer + process.stdout.write('\x1b[?1049h'); console.clear(); const { waitUntilExit } = render(renderUI()); await waitUntilExit(); + // Exit alternate screen buffer + process.stdout.write('\x1b[?1049l'); } /** @@ -55,9 +59,13 @@ export class CommandExecutor { } // Interactive mode + // Enter alternate screen buffer + process.stdout.write('\x1b[?1049h'); console.clear(); const { waitUntilExit } = render(renderUI()); await waitUntilExit(); + // Exit alternate screen buffer + process.stdout.write('\x1b[?1049l'); } /** @@ -79,8 +87,12 @@ export class CommandExecutor { } // Interactive mode + // Enter alternate screen buffer + process.stdout.write('\x1b[?1049h'); const { waitUntilExit } = render(renderUI()); await waitUntilExit(); + // Exit alternate screen buffer + process.stdout.write('\x1b[?1049l'); } /** From cc266246e2d780f62db2b6c01b361c01ec22d118 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 15:59:38 -0700 Subject: [PATCH 02/25] cp dines --- src/cli.ts | 8 +- src/commands/blueprint/list.tsx | 9 +-- src/commands/devbox/list.tsx | 19 +++-- src/commands/menu.tsx | 53 +++++++++++++ src/commands/snapshot/list.tsx | 16 ++-- src/components/Banner.tsx | 17 ++--- src/components/Breadcrumb.tsx | 32 ++++---- src/components/MainMenu.tsx | 128 ++++++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 52 deletions(-) create mode 100644 src/commands/menu.tsx create mode 100644 src/components/MainMenu.tsx diff --git a/src/cli.ts b/src/cli.ts index 2b641405..981cffe3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -125,4 +125,10 @@ if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.lengt } } -program.parse(); +// If no command provided, show main menu +if (args.length === 0) { + const { runMainMenu } = await import('./commands/menu.js'); + runMainMenu(); +} else { + program.parse(); +} diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index c8e21c83..5eef8ad7 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -4,7 +4,6 @@ import TextInput from 'ink-text-input'; import figures from 'figures'; import { getClient } from '../../utils/client.js'; import { Header } from '../../components/Header.js'; -import { Banner } from '../../components/Banner.js'; import { SpinnerComponent } from '../../components/Spinner.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; import { SuccessMessage } from '../../components/SuccessMessage.js'; @@ -260,8 +259,8 @@ const ListBlueprintsUI: React.FC = () => { exec(openCommand); }; openBrowser(); - } else if (input === 'q') { - process.exit(0); + } else if (key.escape) { + exit(); } }); @@ -534,9 +533,7 @@ const ListBlueprintsUI: React.FC = () => { // List view return ( <> - {/* // */} - {/*
*/} {loading && } {!loading && !error && blueprints.length === 0 && ( @@ -640,7 +637,7 @@ const ListBlueprintsUI: React.FC = () => { )} {' '} - [q] Quit + [Esc] Back diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index d98369e4..b7766510 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -317,13 +317,16 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { openBrowser(); } else if (input === '/') { setSearchMode(true); - } else if (key.escape && searchQuery) { - // Clear search when Esc is pressed and there's an active search - setSearchQuery(''); - setCurrentPage(0); - setSelectedIndex(0); - } else if (input === 'q') { - process.exit(0); + } else if (key.escape) { + if (searchQuery) { + // Clear search when Esc is pressed and there's an active search + setSearchQuery(''); + setCurrentPage(0); + setSelectedIndex(0); + } else { + // Go back to home + exit(); + } } }); @@ -727,7 +730,7 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { )} - {' '}• [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [q] Quit + {' '}• [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [Esc] Back diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx new file mode 100644 index 00000000..a90cdc4e --- /dev/null +++ b/src/commands/menu.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render } from 'ink'; +import { MainMenu } from '../components/MainMenu.js'; +import { listDevboxes } from './devbox/list.js'; +import { listBlueprints } from './blueprint/list.js'; +import { listSnapshots } from './snapshot/list.js'; + +export async function showMainMenu() { + return new Promise((resolve) => { + const { waitUntilExit } = render( + { + resolve(key); + }} + /> + ); + + waitUntilExit().then(() => { + // If the user quits without selecting, resolve with empty string + resolve(''); + }); + }); +} + +export async function runMainMenu() { + while (true) { + console.clear(); + const selection = await showMainMenu(); + + if (!selection) { + // User quit + process.exit(0); + } + + console.clear(); + + // Navigate to the selected list view + switch (selection) { + case 'devboxes': + await listDevboxes({ output: undefined }); + break; + case 'blueprints': + await listBlueprints({ output: undefined }); + break; + case 'snapshots': + await listSnapshots({ output: undefined }); + break; + default: + // Unknown selection, return to menu + continue; + } + } +} diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index d43193da..3166162f 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { render, Box, Text, useInput, useStdout } from 'ink'; +import { render, Box, Text, useInput, useStdout, useApp } from 'ink'; import figures from 'figures'; import { getClient } from '../../utils/client.js'; -import { Header } from '../../components/Header.js'; -import { Banner } from '../../components/Banner.js'; import { SpinnerComponent } from '../../components/Spinner.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; import { StatusBadge } from '../../components/StatusBadge.js'; @@ -43,6 +41,7 @@ const formatTimeAgo = (timestamp: number): string => { const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { const { stdout } = useStdout(); + const { exit } = useApp(); const [loading, setLoading] = React.useState(true); const [snapshots, setSnapshots] = React.useState([]); const [error, setError] = React.useState(null); @@ -99,8 +98,8 @@ const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { } else if ((input === 'p' || key.leftArrow) && currentPage > 0) { setCurrentPage(currentPage - 1); setSelectedIndex(0); - } else if (input === 'q') { - process.exit(0); + } else if (key.escape) { + exit(); } }); @@ -114,17 +113,12 @@ const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { return ( <> - -
{loading && } {!loading && !error && snapshots.length === 0 && ( @@ -212,7 +206,7 @@ const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { )} {' '} - [q] Quit + [Esc] Back diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index eee0f492..672510b4 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,20 +1,17 @@ import React from 'react'; import { Box, Text } from 'ink'; +import BigText from 'ink-big-text'; import Gradient from 'ink-gradient'; -import figures from 'figures'; export const Banner: React.FC = React.memo(() => { return ( - - - - {` -╦═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔═╗ -╠╦╝║ ║║║║║ ║ ║║ ║╠═╝ -╩╚═╚═╝╝╚╝╩═╝╚═╝╚═╝╩ .ai - `} - + + + + + .ai + ); }); diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index d64890bf..504e2a0c 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Box, Text } from 'ink'; -import figures from 'figures'; interface BreadcrumbItem { label: string; @@ -16,21 +15,22 @@ export const Breadcrumb: React.FC = React.memo(({ items }) => { const isDevEnvironment = baseUrl && baseUrl !== 'https://api.runloop.ai'; return ( - - RL - {isDevEnvironment && (dev)} - - {figures.arrowRight} - {items.map((item, index) => ( - - - {item.label} - - {index < items.length - 1 && ( - / - )} - - ))} + + + rl + {isDevEnvironment && (dev)} + + {items.map((item, index) => ( + + + {item.label} + + {index < items.length - 1 && ( + + )} + + ))} + ); }); diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx new file mode 100644 index 00000000..f5bbf356 --- /dev/null +++ b/src/components/MainMenu.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { Box, Text, useInput, useApp } from 'ink'; +import figures from 'figures'; +import { Banner } from './Banner.js'; +import { Breadcrumb } from './Breadcrumb.js'; + +interface MenuItem { + key: string; + label: string; + description: string; + icon: string; + color: string; +} + +interface MainMenuProps { + onSelect: (key: string) => void; +} + +export const MainMenu: React.FC = ({ onSelect }) => { + const { exit } = useApp(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + const menuItems: MenuItem[] = [ + { + key: 'devboxes', + label: 'Devboxes', + description: 'Manage cloud development environments', + icon: '◉', + color: 'cyan', + }, + { + key: 'blueprints', + label: 'Blueprints', + description: 'Create and manage devbox templates', + icon: '▣', + color: 'magenta', + }, + { + key: 'snapshots', + label: 'Snapshots', + description: 'Save and restore devbox states', + icon: '◈', + color: 'green', + }, + ]; + + useInput((input, key) => { + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < menuItems.length - 1) { + setSelectedIndex(selectedIndex + 1); + } else if (key.return) { + onSelect(menuItems[selectedIndex].key); + } else if (key.escape) { + exit(); + } else if (input === 'd' || input === '1') { + onSelect('devboxes'); + } else if (input === 'b' || input === '2') { + onSelect('blueprints'); + } else if (input === 's' || input === '3') { + onSelect('snapshots'); + } + }); + + return ( + <> + + + + + + + + + + Cloud development environments for your team + + + + + + + + Select a resource: + + + + {menuItems.map((item, index) => { + const isSelected = index === selectedIndex; + return ( + + + {item.icon} + + + + {item.label} + + + + {item.description} + + + + [{index + 1}] + + + ); + })} + + + + + + {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + + + + + ); +}; From 56b164c86b3574a259f477b5d0b6eecbb139197c Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 16:08:33 -0700 Subject: [PATCH 03/25] cp dines --- src/commands/devbox/list.tsx | 2 +- src/components/DevboxActionsMenu.tsx | 59 +++++++++++++++++----------- src/components/DevboxDetailPage.tsx | 6 ++- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index b7766510..7a29f92b 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -415,7 +415,7 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { { label: selectedDevbox.name || selectedDevbox.id, active: true } ]} initialOperation={selectedOp?.key} - initialOperationIndex={selectedOperation} + skipOperationsMenu={true} /> ); } diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index bcfdd874..5519fffd 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -17,6 +17,7 @@ interface DevboxActionsMenuProps { breadcrumbItems?: Array<{ label: string; active?: boolean }>; initialOperation?: string; // Operation to execute immediately initialOperationIndex?: number; // Index of the operation to select + skipOperationsMenu?: boolean; // Skip showing operations menu and execute immediately } export const DevboxActionsMenu: React.FC = ({ @@ -28,6 +29,7 @@ export const DevboxActionsMenu: React.FC = ({ ], initialOperation, initialOperationIndex = 0, + skipOperationsMenu = false, }) => { const { exit } = useApp(); const { stdout } = useStdout(); @@ -79,7 +81,8 @@ export const DevboxActionsMenu: React.FC = ({ // Auto-execute operations that don't need input React.useEffect(() => { - if ((executingOperation === 'delete' || executingOperation === 'ssh' || executingOperation === 'logs' || executingOperation === 'suspend' || executingOperation === 'resume') && !loading && devbox) { + const autoExecuteOps = ['delete', 'ssh', 'logs', 'suspend', 'resume']; + if (executingOperation && autoExecuteOps.includes(executingOperation) && !loading && devbox) { executeOperation(); } }, [executingOperation]); @@ -698,31 +701,41 @@ export const DevboxActionsMenu: React.FC = ({ ); } - // Operations selection mode - return ( - <> - - - {figures.play} Operations + // Operations selection mode - only show if not skipping + if (!skipOperationsMenu || !executingOperation) { + return ( + <> + - {operations.map((op, index) => { - const isSelected = index === selectedOperation; - return ( - - {isSelected ? figures.pointer : ' '} - {op.icon} {op.label} - [{op.shortcut}] - - ); - })} + {figures.play} Operations + + {operations.map((op, index) => { + const isSelected = index === selectedOperation; + return ( + + {isSelected ? figures.pointer : ' '} + {op.icon} {op.label} + [{op.shortcut}] + + ); + })} + - - - - {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Select • [q] Back - - + + + {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Select • [q] Back + + + + ); + } + + // If skipOperationsMenu is true and executingOperation is set, show loading while it executes + return ( + <> + + ); }; diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index eb693448..352ae87c 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -308,6 +308,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Actions view - show the DevboxActionsMenu when an action is triggered if (showActions) { + const selectedOp = operations[selectedOperation]; return ( = ({ devbox: init }} breadcrumbItems={[ { label: 'Devboxes' }, - { label: selectedDevbox.name || selectedDevbox.id }, - { label: 'Actions', active: true } + { label: selectedDevbox.name || selectedDevbox.id } ]} + initialOperation={selectedOp?.key} + skipOperationsMenu={true} /> ); } From b4faafc7ebf9249b4c1834b1a4802950b110f3a6 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 16:45:37 -0700 Subject: [PATCH 04/25] cp dines --- src/commands/auth.tsx | 5 +- src/commands/blueprint/list.tsx | 5 +- src/commands/devbox/list.tsx | 81 ++++++++++++++++++---------- src/components/DevboxActionsMenu.tsx | 14 ++++- src/components/DevboxDetailPage.tsx | 8 ++- src/utils/sshSession.ts | 44 +++++++++++++++ src/utils/url.ts | 60 +++++++++++++++++++++ 7 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 src/utils/sshSession.ts create mode 100644 src/utils/url.ts diff --git a/src/commands/auth.tsx b/src/commands/auth.tsx index c543a6fe..889c743d 100644 --- a/src/commands/auth.tsx +++ b/src/commands/auth.tsx @@ -6,6 +6,7 @@ import { setApiKey } from '../utils/config.js'; import { Header } from '../components/Header.js'; import { Banner } from '../components/Banner.js'; import { SuccessMessage } from '../components/SuccessMessage.js'; +import { getSettingsUrl } from '../utils/url.js'; const AuthUI: React.FC = () => { const [apiKey, setApiKeyInput] = React.useState(''); @@ -35,11 +36,11 @@ const AuthUI: React.FC = () => {
Get your key: - https://runloop.ai/settings + {getSettingsUrl()} API Key: - + diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 5eef8ad7..04460458 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -13,6 +13,7 @@ import { MetadataDisplay } from '../../components/MetadataDisplay.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; import { OperationsMenu, Operation } from '../../components/OperationsMenu.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; +import { getBlueprintUrl } from '../../utils/url.js'; const PAGE_SIZE = 10; const MAX_FETCH = 100; @@ -197,7 +198,7 @@ const ListBlueprintsUI: React.FC = () => { setSelectedOperation(0); } else if (input === 'o' && selectedBlueprint) { // Open in browser - const url = `https://platform.runloop.ai/blueprints/${selectedBlueprint.id}`; + const url = getBlueprintUrl(selectedBlueprint.id); const openBrowser = async () => { const { exec } = await import('child_process'); const platform = process.platform; @@ -242,7 +243,7 @@ const ListBlueprintsUI: React.FC = () => { setShowDetails(true); } else if (input === 'o' && selectedBlueprint) { // Open in browser - const url = `https://platform.runloop.ai/blueprints/${selectedBlueprint.id}`; + const url = getBlueprintUrl(selectedBlueprint.id); const openBrowser = async () => { const { exec } = await import('child_process'); const platform = process.platform; diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 7a29f92b..cbb161fa 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -17,6 +17,8 @@ import { DevboxDetailPage } from '../../components/DevboxDetailPage.js'; import { DevboxCreatePage } from '../../components/DevboxCreatePage.js'; import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js'; import { ActionsPopup } from '../../components/ActionsPopup.js'; +import { getDevboxUrl } from '../../utils/url.js'; +import { runSSHSession, type SSHSessionConfig } from '../../utils/sshSession.js'; // Format time ago in a succinct way const formatTimeAgo = (timestamp: number): string => { @@ -47,7 +49,11 @@ interface ListOptions { const DEFAULT_PAGE_SIZE = 10; -const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { +const ListDevboxesUI: React.FC<{ + status?: string; + onSSHRequest?: (config: SSHSessionConfig) => void; + focusDevboxId?: string; +}> = ({ status, onSSHRequest, focusDevboxId }) => { const { exit } = useApp(); const { stdout } = useStdout(); const [loading, setLoading] = React.useState(true); @@ -117,6 +123,18 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' }, ]; + // Check if we need to focus on a specific devbox after returning from SSH + React.useEffect(() => { + if (focusDevboxId && devboxes.length > 0 && !loading) { + // Find the devbox in the current page + const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId); + if (devboxIndex !== -1) { + setSelectedIndex(devboxIndex); + setShowDetails(true); + } + } + }, [devboxes, loading, focusDevboxId]); + React.useEffect(() => { const list = async (isInitialLoad: boolean = false) => { try { @@ -298,7 +316,7 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { setShowCreate(true); } else if (input === 'o' && selectedDevbox) { // Open in browser - const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`; + const url = getDevboxUrl(selectedDevbox.id); const openBrowser = async () => { const { exec } = await import('child_process'); const platform = process.platform; @@ -416,13 +434,20 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { ]} initialOperation={selectedOp?.key} skipOperationsMenu={true} + onSSHRequest={onSSHRequest} /> ); } // Details view if (showDetails && selectedDevbox) { - return setShowDetails(false)} />; + return ( + setShowDetails(false)} + onSSHRequest={onSSHRequest} + /> + ); } // Show popup with table in background @@ -740,9 +765,11 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => { ); }; -export async function listDevboxes(options: ListOptions) { +export async function listDevboxes(options: ListOptions, focusDevboxId?: string) { const executor = createExecutor(options); + let sshSessionConfig: SSHSessionConfig | null = null; + await executor.executeList( async () => { const client = executor.getClient(); @@ -751,33 +778,31 @@ export async function listDevboxes(options: ListOptions) { limit: DEFAULT_PAGE_SIZE, }); }, - () => , + () => ( + { + sshSessionConfig = config; + }} + /> + ), DEFAULT_PAGE_SIZE ); - // Check if we need to spawn SSH after Ink exit - const sshCommand = (global as any).__sshCommand; - if (sshCommand) { - delete (global as any).__sshCommand; - - // Import spawn - const { spawnSync } = await import('child_process'); - - // Clear and show connection message - console.clear(); - console.log(`\nConnecting to devbox ${sshCommand.devboxName}...\n`); - - // Spawn SSH in foreground - const result = spawnSync('ssh', [ - '-i', sshCommand.keyPath, - '-o', `ProxyCommand=${sshCommand.proxyCommand}`, - '-o', 'StrictHostKeyChecking=no', - '-o', 'UserKnownHostsFile=/dev/null', - `${sshCommand.sshUser}@${sshCommand.url}` - ], { - stdio: 'inherit' - }); + // If SSH was requested, handle it now after Ink has exited + if (sshSessionConfig) { + const result = await runSSHSession(sshSessionConfig); + + if (result.shouldRestart) { + console.clear(); + console.log(`\nSSH session ended. Returning to CLI...\n`); + await new Promise(resolve => setTimeout(resolve, 500)); - process.exit(result.status || 0); + // Restart the list view with the devbox ID to focus on + await listDevboxes(options, result.returnToDevboxId); + } else { + process.exit(result.exitCode); + } } } diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 5519fffd..459cea4b 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -8,6 +8,7 @@ import { SpinnerComponent } from './Spinner.js'; import { ErrorMessage } from './ErrorMessage.js'; import { SuccessMessage } from './SuccessMessage.js'; import { Breadcrumb } from './Breadcrumb.js'; +import type { SSHSessionConfig } from '../utils/sshSession.js'; type Operation = 'exec' | 'upload' | 'snapshot' | 'ssh' | 'logs' | 'tunnel' | 'suspend' | 'resume' | 'delete' | null; @@ -18,6 +19,7 @@ interface DevboxActionsMenuProps { initialOperation?: string; // Operation to execute immediately initialOperationIndex?: number; // Index of the operation to select skipOperationsMenu?: boolean; // Skip showing operations menu and execute immediately + onSSHRequest?: (config: SSHSessionConfig) => void; // Callback when SSH is requested } export const DevboxActionsMenu: React.FC = ({ @@ -30,6 +32,7 @@ export const DevboxActionsMenu: React.FC = ({ initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, + onSSHRequest, }) => { const { exit } = useApp(); const { stdout } = useStdout(); @@ -323,15 +326,22 @@ export const DevboxActionsMenu: React.FC = ({ const sshUser = devbox.launch_parameters?.user_parameters?.username || 'user'; const proxyCommand = 'openssl s_client -quiet -verify_quiet -servername %h -connect ssh.runloop.ai:443 2>/dev/null'; - (global as any).__sshCommand = { + const sshConfig: SSHSessionConfig = { keyPath, proxyCommand, sshUser, url: sshKey.url, + devboxId: devbox.id, devboxName: devbox.name || devbox.id }; - exit(); + // Notify parent that SSH is requested + if (onSSHRequest) { + onSSHRequest(sshConfig); + exit(); + } else { + setOperationError(new Error('SSH session handler not configured')); + } break; case 'logs': diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 352ae87c..12fdd700 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -6,10 +6,13 @@ import { StatusBadge } from './StatusBadge.js'; import { MetadataDisplay } from './MetadataDisplay.js'; import { Breadcrumb } from './Breadcrumb.js'; import { DevboxActionsMenu } from './DevboxActionsMenu.js'; +import { getDevboxUrl } from '../utils/url.js'; +import type { SSHSessionConfig } from '../utils/sshSession.js'; interface DevboxDetailPageProps { devbox: any; onBack: () => void; + onSSHRequest?: (config: SSHSessionConfig) => void; } // Format time ago in a succinct way @@ -34,7 +37,7 @@ const formatTimeAgo = (timestamp: number): string => { return `${years}y ago`; }; -export const DevboxDetailPage: React.FC = ({ devbox: initialDevbox, onBack }) => { +export const DevboxDetailPage: React.FC = ({ devbox: initialDevbox, onBack, onSSHRequest }) => { const { stdout } = useStdout(); const [showDetailedInfo, setShowDetailedInfo] = React.useState(false); const [detailScroll, setDetailScroll] = React.useState(0); @@ -142,7 +145,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init if (input === 'o') { // Open in browser - const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`; + const url = getDevboxUrl(selectedDevbox.id); const openBrowser = async () => { const { exec } = await import('child_process'); const platform = process.platform; @@ -322,6 +325,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init ]} initialOperation={selectedOp?.key} skipOperationsMenu={true} + onSSHRequest={onSSHRequest} /> ); } diff --git a/src/utils/sshSession.ts b/src/utils/sshSession.ts new file mode 100644 index 00000000..8c89aae1 --- /dev/null +++ b/src/utils/sshSession.ts @@ -0,0 +1,44 @@ +import { spawnSync } from 'child_process'; + +export interface SSHSessionConfig { + keyPath: string; + proxyCommand: string; + sshUser: string; + url: string; + devboxId: string; + devboxName: string; +} + +export interface SSHSessionResult { + exitCode: number; + shouldRestart: boolean; + returnToDevboxId?: string; +} + +export async function runSSHSession(config: SSHSessionConfig): Promise { + // Reset terminal to fix input visibility issues + // This ensures the terminal is in a proper state after exiting Ink + spawnSync('reset', [], { stdio: 'inherit' }); + + console.clear(); + console.log(`\nConnecting to devbox ${config.devboxName}...\n`); + + // Spawn SSH in foreground with proper terminal settings + const result = spawnSync('ssh', [ + '-t', // Force pseudo-terminal allocation for proper input handling + '-i', config.keyPath, + '-o', `ProxyCommand=${config.proxyCommand}`, + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + `${config.sshUser}@${config.url}` + ], { + stdio: 'inherit', + shell: false + }); + + return { + exitCode: result.status || 0, + shouldRestart: true, + returnToDevboxId: config.devboxId + }; +} diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 00000000..da333723 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,60 @@ +/** + * Utility functions for generating URLs + */ + +/** + * Get the base URL for the Runloop platform based on environment + */ +export function getBaseUrl(): string { + const baseUrl = process.env.RUNLOOP_BASE_URL; + + // If RUNLOOP_BASE_URL is explicitly set, use it + if (baseUrl) { + return baseUrl; + } + + // Default to production + return 'https://platform.runloop.ai'; +} + +/** + * Generate a devbox URL for the given devbox ID + */ +export function getDevboxUrl(devboxId: string): string { + const baseUrl = getBaseUrl(); + + // If it's not production, use runloop.pro + if (baseUrl !== 'https://platform.runloop.ai') { + return `https://platform.runloop.pro/devboxes/${devboxId}`; + } + + return `https://platform.runloop.ai/devboxes/${devboxId}`; +} + +/** + * Generate a blueprint URL for the given blueprint ID + */ +export function getBlueprintUrl(blueprintId: string): string { + const baseUrl = getBaseUrl(); + + // If it's not production, use runloop.pro + if (baseUrl !== 'https://platform.runloop.ai') { + return `https://platform.runloop.pro/blueprints/${blueprintId}`; + } + + return `https://platform.runloop.ai/blueprints/${blueprintId}`; +} + +/** + * Generate a settings URL + */ +export function getSettingsUrl(): string { + const baseUrl = getBaseUrl(); + + // If it's not production, use runloop.pro + if (baseUrl !== 'https://platform.runloop.ai') { + return 'https://platform.runloop.pro/settings'; + } + + return 'https://platform.runloop.ai/settings'; +} From e136ef2be3b5dfadd5b35ddbdd962c442c93f47d Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 17:16:40 -0700 Subject: [PATCH 05/25] cp dines --- src/cli.ts | 8 ++++++ src/commands/devbox/list.tsx | 6 ++++ src/commands/snapshot/create.tsx | 3 +- src/components/Breadcrumb.tsx | 4 +-- src/components/DevboxActionsMenu.tsx | 6 ++-- src/utils/client.ts | 20 +++++++++++++ src/utils/url.ts | 42 +++++++++------------------- 7 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 981cffe3..006fca28 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,14 @@ import { execCommand } from './commands/devbox/exec.js'; import { uploadFile } from './commands/devbox/upload.js'; import { getConfig } from './utils/config.js'; +// Global Ctrl+C handler to ensure it always exits +process.on('SIGINT', () => { + // Force exit immediately, clearing alternate screen buffer + process.stdout.write('\x1b[?1049l'); + process.stdout.write('\n'); + process.exit(130); // Standard exit code for SIGINT +}); + const program = new Command(); program diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index cbb161fa..a5be8b3e 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -239,6 +239,12 @@ const ListDevboxesUI: React.FC<{ }, [showDetails, showCreate, showActions]); useInput((input, key) => { + // Handle Ctrl+C to force exit + if (key.ctrl && input === 'c') { + process.stdout.write('\x1b[?1049l'); // Exit alternate screen + process.exit(130); + } + const pageDevboxes = currentDevboxes.length; // Skip input handling when in search mode - let TextInput handle it diff --git a/src/commands/snapshot/create.tsx b/src/commands/snapshot/create.tsx index 87e78c58..12da1c28 100644 --- a/src/commands/snapshot/create.tsx +++ b/src/commands/snapshot/create.tsx @@ -117,7 +117,8 @@ const CreateSnapshotUI: React.FC<{ export async function createSnapshot(devboxId: string, options: CreateOptions) { console.clear(); const { waitUntilExit } = render( - + , + ); await waitUntilExit(); } diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 504e2a0c..209465b9 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -11,8 +11,8 @@ interface BreadcrumbProps { } export const Breadcrumb: React.FC = React.memo(({ items }) => { - const baseUrl = process.env.RUNLOOP_BASE_URL; - const isDevEnvironment = baseUrl && baseUrl !== 'https://api.runloop.ai'; + const env = process.env.RUNLOOP_ENV?.toLowerCase(); + const isDevEnvironment = env === 'dev'; return ( diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 459cea4b..c9b7f2b4 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -42,7 +42,7 @@ export const DevboxActionsMenu: React.FC = ({ const [operationInput, setOperationInput] = React.useState(''); const [operationResult, setOperationResult] = React.useState(null); const [operationError, setOperationError] = React.useState(null); - const [logsWrapMode, setLogsWrapMode] = React.useState(true); + const [logsWrapMode, setLogsWrapMode] = React.useState(false); const [logsScroll, setLogsScroll] = React.useState(0); const [execScroll, setExecScroll] = React.useState(0); const [copyStatus, setCopyStatus] = React.useState(null); @@ -324,7 +324,9 @@ export const DevboxActionsMenu: React.FC = ({ fsModule.writeFileSync(keyPath, sshKey.ssh_private_key, { mode: 0o600 }); const sshUser = devbox.launch_parameters?.user_parameters?.username || 'user'; - const proxyCommand = 'openssl s_client -quiet -verify_quiet -servername %h -connect ssh.runloop.ai:443 2>/dev/null'; + const env = process.env.RUNLOOP_ENV?.toLowerCase(); + const sshHost = env === 'dev' ? 'ssh.runloop.pro' : 'ssh.runloop.ai'; + const proxyCommand = `openssl s_client -quiet -verify_quiet -servername %h -connect ${sshHost}:443 2>/dev/null`; const sshConfig: SSHSessionConfig = { keyPath, diff --git a/src/utils/client.ts b/src/utils/client.ts index 1b086db6..81c8746d 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -1,6 +1,23 @@ import Runloop from '@runloop/api-client'; import { getConfig } from './config.js'; +/** + * Get the base URL based on RUNLOOP_ENV environment variable + * - dev: https://api.runloop.pro + * - prod or unset: https://api.runloop.ai (default) + */ +function getBaseUrl(): string { + const env = process.env.RUNLOOP_ENV?.toLowerCase(); + + switch (env) { + case 'dev': + return 'https://api.runloop.pro'; + case 'prod': + default: + return 'https://api.runloop.ai'; + } +} + export function getClient(): Runloop { const config = getConfig(); @@ -8,8 +25,11 @@ export function getClient(): Runloop { throw new Error('API key not configured. Run: rln auth'); } + const baseURL = getBaseUrl(); + return new Runloop({ bearerToken: config.apiKey, + baseURL, timeout: 10000, // 10 seconds instead of default 30 seconds maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) }); diff --git a/src/utils/url.ts b/src/utils/url.ts index da333723..9ee55c85 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -4,17 +4,19 @@ /** * Get the base URL for the Runloop platform based on environment + * - dev: https://platform.runloop.pro + * - prod or unset: https://platform.runloop.ai (default) */ export function getBaseUrl(): string { - const baseUrl = process.env.RUNLOOP_BASE_URL; - - // If RUNLOOP_BASE_URL is explicitly set, use it - if (baseUrl) { - return baseUrl; + const env = process.env.RUNLOOP_ENV?.toLowerCase(); + + switch (env) { + case 'dev': + return 'https://platform.runloop.pro'; + case 'prod': + default: + return 'https://platform.runloop.ai'; } - - // Default to production - return 'https://platform.runloop.ai'; } /** @@ -22,13 +24,7 @@ export function getBaseUrl(): string { */ export function getDevboxUrl(devboxId: string): string { const baseUrl = getBaseUrl(); - - // If it's not production, use runloop.pro - if (baseUrl !== 'https://platform.runloop.ai') { - return `https://platform.runloop.pro/devboxes/${devboxId}`; - } - - return `https://platform.runloop.ai/devboxes/${devboxId}`; + return `${baseUrl}/devboxes/${devboxId}`; } /** @@ -36,13 +32,7 @@ export function getDevboxUrl(devboxId: string): string { */ export function getBlueprintUrl(blueprintId: string): string { const baseUrl = getBaseUrl(); - - // If it's not production, use runloop.pro - if (baseUrl !== 'https://platform.runloop.ai') { - return `https://platform.runloop.pro/blueprints/${blueprintId}`; - } - - return `https://platform.runloop.ai/blueprints/${blueprintId}`; + return `${baseUrl}/blueprints/${blueprintId}`; } /** @@ -50,11 +40,5 @@ export function getBlueprintUrl(blueprintId: string): string { */ export function getSettingsUrl(): string { const baseUrl = getBaseUrl(); - - // If it's not production, use runloop.pro - if (baseUrl !== 'https://platform.runloop.ai') { - return 'https://platform.runloop.pro/settings'; - } - - return 'https://platform.runloop.ai/settings'; + return `${baseUrl}/settings`; } From 50492eca2e3e2cb7248670d1a72690bacaffbd72 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 17:22:34 -0700 Subject: [PATCH 06/25] cp dines --- src/commands/menu.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index a90cdc4e..f752a019 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -23,12 +23,17 @@ export async function showMainMenu() { } export async function runMainMenu() { + // Enter alternate screen buffer + process.stdout.write('\x1b[?1049h'); + while (true) { console.clear(); const selection = await showMainMenu(); if (!selection) { // User quit + // Exit alternate screen buffer + process.stdout.write('\x1b[?1049l'); process.exit(0); } From be2c34845a4fa60d69f7d2cfe989d8c8eca1f162 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 17:53:58 -0700 Subject: [PATCH 07/25] cp dines --- src/cli.ts | 24 +++++++- src/commands/blueprint/list.tsx | 18 +++++- src/commands/devbox/list.tsx | 17 +++++- src/commands/menu.tsx | 103 ++++++++++++++++---------------- src/commands/snapshot/list.tsx | 19 +++++- src/components/Banner.tsx | 14 ++--- src/components/MainMenu.tsx | 61 ++++++++++++++++++- src/utils/interactiveCommand.ts | 14 +++++ 8 files changed, 199 insertions(+), 71 deletions(-) create mode 100644 src/utils/interactiveCommand.ts diff --git a/src/cli.ts b/src/cli.ts index 006fca28..19ae44a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,7 +50,15 @@ devbox .description('List all devboxes') .option('-s, --status ', 'Filter by status') .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') - .action(listDevboxes); + .action(async (options) => { + // Only use alternate screen for interactive mode + if (!options.output) { + const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); + await runInteractiveCommand(() => listDevboxes(options)); + } else { + await listDevboxes(options); + } + }); devbox .command('delete ') @@ -85,7 +93,12 @@ snapshot .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') .action(async (options) => { const { listSnapshots } = await import('./commands/snapshot/list.js'); - listSnapshots(options); + if (!options.output) { + const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); + await runInteractiveCommand(() => listSnapshots(options)); + } else { + await listSnapshots(options); + } }); snapshot @@ -120,7 +133,12 @@ blueprint .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') .action(async (options) => { const { listBlueprints } = await import('./commands/blueprint/list.js'); - listBlueprints(options); + if (!options.output) { + const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); + await runInteractiveCommand(() => listBlueprints(options)); + } else { + await listBlueprints(options); + } }); // Check if API key is configured (except for auth command) diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 04460458..8ec28f02 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -42,9 +42,12 @@ const formatTimeAgo = (timestamp: number): string => { return `${years}y ago`; }; -const ListBlueprintsUI: React.FC = () => { +const ListBlueprintsUI: React.FC<{ + onBack?: () => void; + onExit?: () => void; +}> = ({ onBack, onExit }) => { const { stdout } = useStdout(); - const { exit } = useApp(); + const { exit: inkExit } = useApp(); const [loading, setLoading] = React.useState(true); const [blueprints, setBlueprints] = React.useState([]); const [error, setError] = React.useState(null); @@ -261,7 +264,13 @@ const ListBlueprintsUI: React.FC = () => { }; openBrowser(); } else if (key.escape) { - exit(); + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } } }); @@ -652,6 +661,9 @@ interface ListBlueprintsOptions { output?: string; } +// Export the UI component for use in the main menu +export { ListBlueprintsUI }; + export async function listBlueprints(options: ListBlueprintsOptions = {}) { const executor = createExecutor(options); diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index a5be8b3e..de5b73fd 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -53,8 +53,10 @@ const ListDevboxesUI: React.FC<{ status?: string; onSSHRequest?: (config: SSHSessionConfig) => void; focusDevboxId?: string; -}> = ({ status, onSSHRequest, focusDevboxId }) => { - const { exit } = useApp(); + onBack?: () => void; + onExit?: () => void; +}> = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => { + const { exit: inkExit } = useApp(); const { stdout } = useStdout(); const [loading, setLoading] = React.useState(true); const [devboxes, setDevboxes] = React.useState([]); @@ -349,7 +351,13 @@ const ListDevboxesUI: React.FC<{ setSelectedIndex(0); } else { // Go back to home - exit(); + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } } } }); @@ -771,6 +779,9 @@ const ListDevboxesUI: React.FC<{ ); }; +// Export the UI component for use in the main menu +export { ListDevboxesUI }; + export async function listDevboxes(options: ListOptions, focusDevboxId?: string) { const executor = createExecutor(options); diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index f752a019..ed1f95c9 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -1,58 +1,61 @@ import React from 'react'; -import { render } from 'ink'; +import { render, useApp } from 'ink'; import { MainMenu } from '../components/MainMenu.js'; -import { listDevboxes } from './devbox/list.js'; -import { listBlueprints } from './blueprint/list.js'; -import { listSnapshots } from './snapshot/list.js'; - -export async function showMainMenu() { - return new Promise((resolve) => { - const { waitUntilExit } = render( - { - resolve(key); - }} - /> - ); - - waitUntilExit().then(() => { - // If the user quits without selecting, resolve with empty string - resolve(''); - }); - }); -} + +// Import list components dynamically to avoid circular deps +type Screen = 'menu' | 'devboxes' | 'blueprints' | 'snapshots'; + +// Import the UI components directly +import { ListDevboxesUI } from './devbox/list.js'; +import { ListBlueprintsUI } from './blueprint/list.js'; +import { ListSnapshotsUI } from './snapshot/list.js'; + +const App: React.FC = () => { + const { exit } = useApp(); + const [currentScreen, setCurrentScreen] = React.useState('menu'); + + const handleMenuSelect = (key: string) => { + setCurrentScreen(key as Screen); + }; + + const handleBack = () => { + setCurrentScreen('menu'); + }; + + const handleExit = () => { + exit(); + }; + + if (currentScreen === 'menu') { + return ; + } + + if (currentScreen === 'devboxes') { + return ; + } + + if (currentScreen === 'blueprints') { + return ; + } + + if (currentScreen === 'snapshots') { + return ; + } + + return null; +}; export async function runMainMenu() { - // Enter alternate screen buffer + // Enter alternate screen buffer once at the start process.stdout.write('\x1b[?1049h'); - while (true) { - console.clear(); - const selection = await showMainMenu(); - - if (!selection) { - // User quit - // Exit alternate screen buffer - process.stdout.write('\x1b[?1049l'); - process.exit(0); - } - - console.clear(); - - // Navigate to the selected list view - switch (selection) { - case 'devboxes': - await listDevboxes({ output: undefined }); - break; - case 'blueprints': - await listBlueprints({ output: undefined }); - break; - case 'snapshots': - await listSnapshots({ output: undefined }); - break; - default: - // Unknown selection, return to menu - continue; - } + try { + const { waitUntilExit } = render(); + await waitUntilExit(); + } finally { + // Exit alternate screen buffer once at the end + process.stdout.write('\x1b[?1049l'); } + + process.exit(0); } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 3166162f..0b439046 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -39,9 +39,13 @@ const formatTimeAgo = (timestamp: number): string => { return `${years}y ago`; }; -const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { +const ListSnapshotsUI: React.FC<{ + devboxId?: string; + onBack?: () => void; + onExit?: () => void; +}> = ({ devboxId, onBack, onExit }) => { const { stdout } = useStdout(); - const { exit } = useApp(); + const { exit: inkExit } = useApp(); const [loading, setLoading] = React.useState(true); const [snapshots, setSnapshots] = React.useState([]); const [error, setError] = React.useState(null); @@ -99,7 +103,13 @@ const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { setCurrentPage(currentPage - 1); setSelectedIndex(0); } else if (key.escape) { - exit(); + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } } }); @@ -216,6 +226,9 @@ const ListSnapshotsUI: React.FC<{ devboxId?: string }> = ({ devboxId }) => { ); }; +// Export the UI component for use in the main menu +export { ListSnapshotsUI }; + export async function listSnapshots(options: ListOptions) { const executor = createExecutor(options); diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 672510b4..897e7aec 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -5,13 +5,13 @@ import Gradient from 'ink-gradient'; export const Banner: React.FC = React.memo(() => { return ( - - - - - - .ai - + + + + + {/* + .ai + */} ); }); diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index f5bbf356..0c09fb41 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -20,6 +20,9 @@ export const MainMenu: React.FC = ({ onSelect }) => { const { exit } = useApp(); const [selectedIndex, setSelectedIndex] = React.useState(0); + // Calculate terminal height once at mount + const terminalHeight = process.stdout.rows || 24; + const menuItems: MenuItem[] = [ { key: 'devboxes', @@ -62,8 +65,62 @@ export const MainMenu: React.FC = ({ onSelect }) => { } }); + // Use compact layout if terminal height is less than 20 lines + const useCompactLayout = terminalHeight < 20; + + if (useCompactLayout) { + return ( + + + + RUNLOOP.ai + + + {' '} + • Cloud development environments + + + + + {menuItems.map((item, index) => { + const isSelected = index === selectedIndex; + return ( + + + {isSelected ? figures.pointer : ' '} + + + + {item.icon} + + + + {item.label} + + + {' '} + - {item.description} + + + {' '} + [{index + 1}] + + + ); + })} + + + + + {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + + + + ); + } + return ( - <> + @@ -123,6 +180,6 @@ export const MainMenu: React.FC = ({ onSelect }) => { - + ); }; diff --git a/src/utils/interactiveCommand.ts b/src/utils/interactiveCommand.ts new file mode 100644 index 00000000..a7427d94 --- /dev/null +++ b/src/utils/interactiveCommand.ts @@ -0,0 +1,14 @@ +/** + * Wrapper for interactive commands that need alternate screen buffer management + */ +export async function runInteractiveCommand(command: () => Promise) { + // Enter alternate screen buffer + process.stdout.write('\x1b[?1049h'); + + try { + await command(); + } finally { + // Exit alternate screen buffer + process.stdout.write('\x1b[?1049l'); + } +} From 025e87883fd5007e00a00c9c21fa772cc5d28d9a Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 17:55:13 -0700 Subject: [PATCH 08/25] cp dines --- src/commands/menu.tsx | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index ed1f95c9..72e53eb7 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -10,39 +10,38 @@ import { ListDevboxesUI } from './devbox/list.js'; import { ListBlueprintsUI } from './blueprint/list.js'; import { ListSnapshotsUI } from './snapshot/list.js'; +import { Box } from 'ink'; + const App: React.FC = () => { const { exit } = useApp(); const [currentScreen, setCurrentScreen] = React.useState('menu'); + const [, forceUpdate] = React.useReducer(x => x + 1, 0); const handleMenuSelect = (key: string) => { setCurrentScreen(key as Screen); + // Force a full re-render to clear any stale content + setTimeout(() => forceUpdate(), 0); }; const handleBack = () => { setCurrentScreen('menu'); + // Force a full re-render to clear any stale content + setTimeout(() => forceUpdate(), 0); }; const handleExit = () => { exit(); }; - if (currentScreen === 'menu') { - return ; - } - - if (currentScreen === 'devboxes') { - return ; - } - - if (currentScreen === 'blueprints') { - return ; - } - - if (currentScreen === 'snapshots') { - return ; - } - - return null; + // Wrap everything in a full-height container + return ( + + {currentScreen === 'menu' && } + {currentScreen === 'devboxes' && } + {currentScreen === 'blueprints' && } + {currentScreen === 'snapshots' && } + + ); }; export async function runMainMenu() { From 098e53786f340b239f86170685eea0ff1ce4081b Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 18:12:20 -0700 Subject: [PATCH 09/25] cp dines --- src/commands/menu.tsx | 81 +++++++++++++++++++++++----- src/components/DevboxActionsMenu.tsx | 21 +++++--- src/components/MainMenu.tsx | 16 +++--- 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index 72e53eb7..6796c458 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, useApp } from 'ink'; import { MainMenu } from '../components/MainMenu.js'; +import { runSSHSession, type SSHSessionConfig } from '../utils/sshSession.js'; // Import list components dynamically to avoid circular deps type Screen = 'menu' | 'devboxes' | 'blueprints' | 'snapshots'; @@ -12,21 +13,23 @@ import { ListSnapshotsUI } from './snapshot/list.js'; import { Box } from 'ink'; -const App: React.FC = () => { +interface AppProps { + onSSHRequest: (config: SSHSessionConfig) => void; + initialScreen?: Screen; + focusDevboxId?: string; +} + +const App: React.FC = ({ onSSHRequest, initialScreen = 'menu', focusDevboxId }) => { const { exit } = useApp(); - const [currentScreen, setCurrentScreen] = React.useState('menu'); + const [currentScreen, setCurrentScreen] = React.useState(initialScreen); const [, forceUpdate] = React.useReducer(x => x + 1, 0); const handleMenuSelect = (key: string) => { setCurrentScreen(key as Screen); - // Force a full re-render to clear any stale content - setTimeout(() => forceUpdate(), 0); }; const handleBack = () => { setCurrentScreen('menu'); - // Force a full re-render to clear any stale content - setTimeout(() => forceUpdate(), 0); }; const handleExit = () => { @@ -37,24 +40,74 @@ const App: React.FC = () => { return ( {currentScreen === 'menu' && } - {currentScreen === 'devboxes' && } + {currentScreen === 'devboxes' && ( + + )} {currentScreen === 'blueprints' && } {currentScreen === 'snapshots' && } ); }; -export async function runMainMenu() { +export async function runMainMenu(initialScreen: Screen = 'menu', focusDevboxId?: string) { // Enter alternate screen buffer once at the start process.stdout.write('\x1b[?1049h'); - try { - const { waitUntilExit } = render(); - await waitUntilExit(); - } finally { - // Exit alternate screen buffer once at the end - process.stdout.write('\x1b[?1049l'); + let sshSessionConfig: SSHSessionConfig | null = null; + let shouldContinue = true; + let currentInitialScreen = initialScreen; + let currentFocusDevboxId = focusDevboxId; + + while (shouldContinue) { + sshSessionConfig = null; + + try { + const { waitUntilExit } = render( + { + sshSessionConfig = config; + }} + initialScreen={currentInitialScreen} + focusDevboxId={currentFocusDevboxId} + /> + ); + await waitUntilExit(); + shouldContinue = false; + } catch (error) { + console.error('Error in menu:', error); + shouldContinue = false; + } + + // If SSH was requested, handle it now after Ink has exited + if (sshSessionConfig) { + // Exit alternate screen buffer for SSH + process.stdout.write('\x1b[?1049l'); + + const result = await runSSHSession(sshSessionConfig); + + if (result.shouldRestart) { + console.clear(); + console.log(`\nSSH session ended. Returning to menu...\n`); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Re-enter alternate screen buffer and return to devboxes list + process.stdout.write('\x1b[?1049h'); + currentInitialScreen = 'devboxes'; + currentFocusDevboxId = result.returnToDevboxId; + shouldContinue = true; + } else { + shouldContinue = false; + } + } } + // Exit alternate screen buffer once at the end + process.stdout.write('\x1b[?1049l'); + process.exit(0); } diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index c9b7f2b4..71ab78f8 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -107,14 +107,19 @@ export const DevboxActionsMenu: React.FC = ({ if (operationResult || operationError) { if (input === 'q' || key.escape || key.return) { console.clear(); - setOperationResult(null); - setOperationError(null); - setExecutingOperation(null); - setOperationInput(''); - setLogsWrapMode(true); - setLogsScroll(0); - setExecScroll(0); - setCopyStatus(null); + // If skipOperationsMenu is true, go back to parent instead of operations menu + if (skipOperationsMenu) { + onBack(); + } else { + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setOperationInput(''); + setLogsWrapMode(true); + setLogsScroll(0); + setExecScroll(0); + setCopyStatus(null); + } } else if ((key.upArrow || input === 'k') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { setExecScroll(Math.max(0, execScroll - 1)); } else if ((key.downArrow || input === 'j') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 0c09fb41..7e9adabe 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -16,14 +16,14 @@ interface MainMenuProps { onSelect: (key: string) => void; } -export const MainMenu: React.FC = ({ onSelect }) => { +export const MainMenu: React.FC = React.memo(({ onSelect }) => { const { exit } = useApp(); const [selectedIndex, setSelectedIndex] = React.useState(0); - // Calculate terminal height once at mount - const terminalHeight = process.stdout.rows || 24; + // Calculate terminal height once at mount and memoize + const terminalHeight = React.useMemo(() => process.stdout.rows || 24, []); - const menuItems: MenuItem[] = [ + const menuItems: MenuItem[] = React.useMemo(() => [ { key: 'devboxes', label: 'Devboxes', @@ -45,7 +45,7 @@ export const MainMenu: React.FC = ({ onSelect }) => { icon: '◈', color: 'green', }, - ]; + ], []); useInput((input, key) => { if (key.upArrow && selectedIndex > 0) { @@ -65,8 +65,8 @@ export const MainMenu: React.FC = ({ onSelect }) => { } }); - // Use compact layout if terminal height is less than 20 lines - const useCompactLayout = terminalHeight < 20; + // Use compact layout if terminal height is less than 20 lines (memoized) + const useCompactLayout = React.useMemo(() => terminalHeight < 20, [terminalHeight]); if (useCompactLayout) { return ( @@ -182,4 +182,4 @@ export const MainMenu: React.FC = ({ onSelect }) => { ); -}; +}); From 5694a65e46a851b7e791c6f91db5466729b9ea32 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 18:17:43 -0700 Subject: [PATCH 10/25] cp dines --- RELEASE.md | 118 ++++++++++++++++++++++++++++++++++++ src/cli.ts | 46 ++++++++------ src/components/MainMenu.tsx | 5 +- 3 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..58bfe509 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,118 @@ +# Release Process + +This document outlines the manual release process for `@runloop/rl-cli`. + +## Prerequisites + +1. Ensure you have npm access to publish to the `@runloop` organization +2. Ensure your local repository is clean and up to date with `main` +3. Ensure all tests pass and the build succeeds + +## Release Steps + +### 1. Update Version + +Choose the appropriate version bump based on your changes: + +```bash +# For bug fixes and minor changes +npm run version:patch + +# For new features (backward compatible) +npm run version:minor + +# For breaking changes +npm run version:major +``` + +This will: +- Update the version in `package.json` +- Create a git commit with the version bump +- Create a git tag for the new version + +### 2. Push Changes + +Push the version commit and tag to GitHub: + +```bash +git push origin improvements --follow-tags +``` + +### 3. Build and Publish + +Build and publish the package to npm: + +```bash +npm run release +``` + +This will: +- Run the TypeScript compiler to build the project +- Publish the package to npm + +### 4. Verify Publication + +Verify the package was published successfully: + +```bash +npm view @runloop/rl-cli version +``` + +### 5. Create GitHub Release (Optional) + +Create a GitHub release for the new version: + +1. Go to https://github.com/runloop/rl-cli-node/releases +2. Click "Draft a new release" +3. Select the tag you just pushed +4. Add release notes describing the changes +5. Publish the release + +## Version Commands Reference + +- `npm run version:patch` - Bump patch version (e.g., 0.0.2 → 0.0.3) +- `npm run version:minor` - Bump minor version (e.g., 0.0.2 → 0.1.0) +- `npm run version:major` - Bump major version (e.g., 0.0.2 → 1.0.0) +- `npm run release` - Build and publish to npm + +## Troubleshooting + +### Authentication Issues + +If you get authentication errors when publishing: + +```bash +npm login +``` + +Follow the prompts to log in to your npm account. + +### Build Errors + +If the build fails, fix the errors and run: + +```bash +npm run build +``` + +### Reverting a Release + +If you need to revert a release: + +```bash +# Deprecate the version on npm (doesn't unpublish) +npm deprecate @runloop/rl-cli@ "Reason for deprecation" + +# Revert the git commit and tag locally +git reset --hard HEAD~1 +git tag -d v +git push origin :refs/tags/v +``` + +Note: You cannot unpublish a package version after 72 hours. Use deprecation instead. + +## Notes + +- The `prepublishOnly` script automatically runs `npm run build` before publishing +- The version is automatically read from `package.json` and displayed in the CLI +- Make sure to update the changelog or release notes with each version diff --git a/src/cli.ts b/src/cli.ts index 19ae44a6..fff2ca04 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,15 @@ import { deleteDevbox } from './commands/devbox/delete.js'; import { execCommand } from './commands/devbox/exec.js'; import { uploadFile } from './commands/devbox/upload.js'; import { getConfig } from './utils/config.js'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// Get version from package.json +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')); +export const VERSION = packageJson.version; // Global Ctrl+C handler to ensure it always exits process.on('SIGINT', () => { @@ -21,7 +30,7 @@ const program = new Command(); program .name('rln') .description('Beautiful CLI for Runloop devbox management') - .version('1.0.0'); + .version(VERSION); program .command('auth') @@ -141,20 +150,23 @@ blueprint } }); -// Check if API key is configured (except for auth command) -const args = process.argv.slice(2); -if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) { - const config = getConfig(); - if (!config.apiKey) { - console.error('\n❌ API key not configured. Run: rln auth\n'); - process.exit(1); +// Main CLI entry point +(async () => { + // Check if API key is configured (except for auth command) + const args = process.argv.slice(2); + if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) { + const config = getConfig(); + if (!config.apiKey) { + console.error('\n❌ API key not configured. Run: rln auth\n'); + process.exit(1); + } + } + + // If no command provided, show main menu + if (args.length === 0) { + const { runMainMenu } = await import('./commands/menu.js'); + runMainMenu(); + } else { + program.parse(); } -} - -// If no command provided, show main menu -if (args.length === 0) { - const { runMainMenu } = await import('./commands/menu.js'); - runMainMenu(); -} else { - program.parse(); -} +})(); diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 7e9adabe..f73deb32 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp } from 'ink'; import figures from 'figures'; import { Banner } from './Banner.js'; import { Breadcrumb } from './Breadcrumb.js'; +import { VERSION } from '../cli.js'; interface MenuItem { key: string; @@ -77,7 +78,7 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { {' '} - • Cloud development environments + • Cloud development environments • v{VERSION} @@ -130,7 +131,7 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { - Cloud development environments for your team + Cloud development environments for your team • v{VERSION} From e3eee7eb918bfff492d73d74c43cd1cf6b25ebd2 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 6 Oct 2025 18:18:12 -0700 Subject: [PATCH 11/25] 0.0.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe4ab796..15b18b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.0.1", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.0.1", + "version": "0.0.3", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/package.json b/package.json index 65970a55..97a9abe8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.0.2", + "version": "0.0.3", "description": "Beautiful CLI for Runloop devbox management", "type": "module", "bin": { From ed87a81b00bab7ce4f63cc4426956062489aa985 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 12:19:33 -0700 Subject: [PATCH 12/25] cp dines --- src/commands/auth.tsx | 9 +- src/commands/blueprint/list.tsx | 65 +++++++------- src/commands/devbox/create.tsx | 5 +- src/commands/devbox/exec.tsx | 3 +- src/commands/devbox/list.tsx | 73 +++++++-------- src/commands/snapshot/create.tsx | 23 ++--- src/commands/snapshot/list.tsx | 27 +++--- src/components/ActionsPopup.tsx | 17 ++-- src/components/Breadcrumb.tsx | 11 +-- src/components/DetailView.tsx | 5 +- src/components/DevboxActionsMenu.tsx | 127 ++++++++++++++------------- src/components/DevboxCard.tsx | 17 ++-- src/components/DevboxCreatePage.tsx | 61 ++++++------- src/components/DevboxDetailPage.tsx | 93 ++++++++++---------- src/components/ErrorMessage.tsx | 5 +- src/components/Header.tsx | 7 +- src/components/MainMenu.tsx | 50 ++++++----- src/components/MetadataDisplay.tsx | 13 +-- src/components/OperationsMenu.tsx | 9 +- src/components/Spinner.tsx | 3 +- src/components/StatusBadge.tsx | 29 +++--- src/components/SuccessMessage.tsx | 5 +- src/components/Table.example.tsx | 21 ++--- src/components/Table.tsx | 11 +-- src/utils/theme.ts | 29 ++++++ 25 files changed, 386 insertions(+), 332 deletions(-) create mode 100644 src/utils/theme.ts diff --git a/src/commands/auth.tsx b/src/commands/auth.tsx index 889c743d..0a746f73 100644 --- a/src/commands/auth.tsx +++ b/src/commands/auth.tsx @@ -7,6 +7,7 @@ import { Header } from '../components/Header.js'; import { Banner } from '../components/Banner.js'; import { SuccessMessage } from '../components/SuccessMessage.js'; import { getSettingsUrl } from '../utils/url.js'; +import { colors } from '../utils/theme.js'; const AuthUI: React.FC = () => { const [apiKey, setApiKeyInput] = React.useState(''); @@ -35,15 +36,15 @@ const AuthUI: React.FC = () => {
- Get your key: - {getSettingsUrl()} + Get your key: + {getSettingsUrl()} - API Key: + API Key: - + Press Enter to save diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 8ec28f02..a6dc1ffb 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -14,6 +14,7 @@ import { Table, createTextColumn, createComponentColumn } from '../../components import { OperationsMenu, Operation } from '../../components/OperationsMenu.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; import { getBlueprintUrl } from '../../utils/url.js'; +import { colors } from '../../utils/theme.js'; const PAGE_SIZE = 10; const MAX_FETCH = 100; @@ -74,7 +75,7 @@ const ListBlueprintsUI: React.FC<{ { key: 'create_devbox', label: 'Create Devbox from Blueprint', - color: 'green', + color: colors.success, icon: figures.play, needsInput: true, inputPrompt: 'Devbox name (optional):', @@ -83,7 +84,7 @@ const ListBlueprintsUI: React.FC<{ { key: 'delete', label: 'Delete Blueprint', - color: 'red', + color: colors.error, icon: figures.cross, }, ]; @@ -319,7 +320,7 @@ const ListBlueprintsUI: React.FC<{ {operationResult && } {operationError && } - + Press [Enter], [q], or [esc] to continue @@ -380,12 +381,12 @@ const ListBlueprintsUI: React.FC<{
- + {selectedBlueprint.name || selectedBlueprint.id} - {currentOp.inputPrompt} + {currentOp.inputPrompt} - + Press [Enter] to execute • [q or esc] Cancel @@ -425,28 +426,28 @@ const ListBlueprintsUI: React.FC<{ - + {selectedBlueprint.name || selectedBlueprint.id} - + {' '} • {selectedBlueprint.id} - + {selectedBlueprint.create_time_ms ? new Date(selectedBlueprint.create_time_ms).toLocaleString() : ''} - + {' '} ( {selectedBlueprint.create_time_ms @@ -457,7 +458,7 @@ const ListBlueprintsUI: React.FC<{ {ds?.description && ( - + {ds.description} @@ -469,11 +470,11 @@ const ListBlueprintsUI: React.FC<{ - + {figures.squareSmallFilled} Dockerfile Setup {ds.base_image && ( @@ -497,18 +498,18 @@ const ListBlueprintsUI: React.FC<{ {/* Metadata */} {selectedBlueprint.metadata && Object.keys(selectedBlueprint.metadata).length > 0 && ( - + )} {/* Failure reason */} {selectedBlueprint.build_error && ( - - + + {figures.cross}{' '} - + {selectedBlueprint.build_error} @@ -547,9 +548,9 @@ const ListBlueprintsUI: React.FC<{ {loading && } {!loading && !error && blueprints.length === 0 && ( - {figures.info} + {figures.info} No blueprints found. Try: - + rln blueprint create @@ -557,26 +558,26 @@ const ListBlueprintsUI: React.FC<{ {!loading && !error && blueprints.length > 0 && ( <> - + {figures.tick} {buildComplete} - + {figures.ellipsis} {building} - + {figures.cross} {failed} - + {figures.hamburger} {blueprints.length} {blueprints.length >= MAX_FETCH && '+'} {totalPages > 1 && ( <> - - + + Page {currentPage + 1}/{totalPages} @@ -600,7 +601,7 @@ const ListBlueprintsUI: React.FC<{ (blueprint: any) => (showFullId ? blueprint.id : blueprint.id.slice(0, 13)), { width: showFullId ? idWidth : 15, - color: 'gray', + color: colors.textDim, dimColor: true, bold: false, } @@ -617,7 +618,7 @@ const ListBlueprintsUI: React.FC<{ (blueprint: any) => blueprint.dockerfile_setup?.description || '', { width: descriptionWidth, - color: 'gray', + color: colors.textDim, dimColor: true, bold: false, visible: showDescription, @@ -628,24 +629,24 @@ const ListBlueprintsUI: React.FC<{ 'Created', (blueprint: any) => blueprint.create_time_ms ? formatTimeAgo(blueprint.create_time_ms) : '', - { width: timeWidth, color: 'gray', dimColor: true, bold: false } + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} /> - + {figures.arrowUp} {figures.arrowDown} Navigate • [Enter] Operations • [o] Open in Browser • {totalPages > 1 && ( - + {' '} {figures.arrowLeft} {figures.arrowRight} Page • )} - + {' '} [Esc] Back diff --git a/src/commands/devbox/create.tsx b/src/commands/devbox/create.tsx index 2722c805..ce2041c5 100644 --- a/src/commands/devbox/create.tsx +++ b/src/commands/devbox/create.tsx @@ -7,6 +7,7 @@ import { SpinnerComponent } from '../../components/Spinner.js'; import { SuccessMessage } from '../../components/SuccessMessage.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; +import { colors } from '../../utils/theme.js'; interface CreateOptions { name?: string; @@ -52,8 +53,8 @@ const CreateDevboxUI: React.FC<{ details={`ID: ${result.id}\nStatus: ${result.status}`} /> - Try: - rln devbox exec {result.id} ls + Try: + rln devbox exec {result.id} ls )} diff --git a/src/commands/devbox/exec.tsx b/src/commands/devbox/exec.tsx index b142e5af..d4280f4e 100644 --- a/src/commands/devbox/exec.tsx +++ b/src/commands/devbox/exec.tsx @@ -4,6 +4,7 @@ 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 { colors } from '../../utils/theme.js'; const ExecCommandUI: React.FC<{ id: string; command: string[] }> = ({ id, @@ -40,7 +41,7 @@ const ExecCommandUI: React.FC<{ id: string; command: string[] }> = ({ {loading && } {!loading && !error && ( - + {output} diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index de5b73fd..75188e87 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -19,6 +19,7 @@ import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js'; import { ActionsPopup } from '../../components/ActionsPopup.js'; import { getDevboxUrl } from '../../utils/url.js'; import { runSSHSession, type SSHSessionConfig } from '../../utils/sshSession.js'; +import { colors } from '../../utils/theme.js'; // Format time ago in a succinct way const formatTimeAgo = (timestamp: number): string => { @@ -114,15 +115,15 @@ const ListDevboxesUI: React.FC<{ // Define allOperations const allOperations = [ - { key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' }, + { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, + { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, + { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, + { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, + { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, + { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, + { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, + { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, + { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, ]; // Check if we need to focus on a specific devbox after returning from SSH @@ -501,7 +502,7 @@ const ListDevboxesUI: React.FC<{ 'id', 'ID', (devbox: any) => devbox.id, - { width: idWidth, color: 'gray', dimColor: true, bold: false } + { width: idWidth, color: colors.textDim, dimColor: true, bold: false } ), { key: 'statusText', @@ -538,19 +539,19 @@ const ListDevboxesUI: React.FC<{ .join(',')}]` : ''; }, - { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities } + { width: capabilitiesWidth, color: colors.info, dimColor: true, bold: false, visible: showCapabilities } ), createTextColumn( 'tags', 'Tags', (devbox: any) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', - { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags } + { width: tagWidth, color: colors.warning, dimColor: true, bold: false, visible: showTags } ), createTextColumn( 'created', 'Created', (devbox: any) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', - { width: timeWidth, color: 'gray', dimColor: true, bold: false } + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} /> @@ -600,9 +601,9 @@ const ListDevboxesUI: React.FC<{ { label: 'Devboxes', active: true } ]} /> - {figures.info} + {figures.info} No devboxes found. Try: - + rln devbox create @@ -620,7 +621,7 @@ const ListDevboxesUI: React.FC<{ <> {searchMode && ( - {figures.pointerSmall} Search: + {figures.pointerSmall} Search: - [Esc to cancel] + [Esc to cancel] )} {!searchMode && searchQuery && ( - {figures.info} Searching for: - {searchQuery} - ({currentDevboxes.length} results) [/ to edit, Esc to clear] + {figures.info} Searching for: + {searchQuery} + ({currentDevboxes.length} results) [/ to edit, Esc to clear] )}
devbox.id, - { width: idWidth, color: 'gray', dimColor: true, bold: false } + { width: idWidth, color: colors.textDim, dimColor: true, bold: false } ), { key: 'statusText', @@ -707,51 +708,51 @@ const ListDevboxesUI: React.FC<{ .join(',')}]` : ''; }, - { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities } + { width: capabilitiesWidth, color: colors.info, dimColor: true, bold: false, visible: showCapabilities } ), createTextColumn( 'tags', 'Tags', (devbox: any) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', - { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags } + { width: tagWidth, color: colors.warning, dimColor: true, bold: false, visible: showTags } ), createTextColumn( 'created', 'Created', (devbox: any) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', - { width: timeWidth, color: 'gray', dimColor: true, bold: false } + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} /> {/* Statistics Bar */} - + {figures.hamburger} {totalCount} - total + total {totalPages > 1 && ( <> - - + + Page {currentPage + 1} of {totalPages} )} - - + + Showing {startIndex + 1}-{endIndex} of {totalCount} {hasMore && ( - (more available) + (more available) )} {refreshing ? ( - + {['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10]} ) : ( - + {figures.circleFilled} )} @@ -759,16 +760,16 @@ const ListDevboxesUI: React.FC<{ {/* Help Bar */} - + {figures.arrowUp} {figures.arrowDown} Navigate {totalPages > 1 && ( - + {' '}• {figures.arrowLeft}{figures.arrowRight} Page )} - + {' '}• [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [Esc] Back diff --git a/src/commands/snapshot/create.tsx b/src/commands/snapshot/create.tsx index 12da1c28..b111dca1 100644 --- a/src/commands/snapshot/create.tsx +++ b/src/commands/snapshot/create.tsx @@ -8,6 +8,7 @@ import { Banner } from '../../components/Banner.js'; import { SpinnerComponent } from '../../components/Spinner.js'; import { SuccessMessage } from '../../components/SuccessMessage.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; +import { colors } from '../../utils/theme.js'; interface CreateOptions { name?: string; @@ -49,26 +50,26 @@ const CreateSnapshotUI: React.FC<{ - + {figures.info} Configuration - {figures.pointer} Devbox ID: - {devboxId} + {figures.pointer} Devbox ID: + {devboxId} {name && ( - {figures.pointer} Name: - {name} + {figures.pointer} Name: + {name} )} @@ -84,7 +85,7 @@ const CreateSnapshotUI: React.FC<{ /> - {figures.tick} View snapshots: - rln snapshot list + {figures.tick} View snapshots: + rln snapshot list - {figures.tick} Create devbox from snapshot: - rln devbox create -t {result.id} + {figures.tick} Create devbox from snapshot: + rln devbox create -t {result.id} diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 0b439046..880acf57 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -8,6 +8,7 @@ import { StatusBadge } from '../../components/StatusBadge.js'; import { Breadcrumb } from '../../components/Breadcrumb.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; +import { colors } from '../../utils/theme.js'; interface ListOptions { devbox?: string; @@ -132,9 +133,9 @@ const ListSnapshotsUI: React.FC<{ {loading && } {!loading && !error && snapshots.length === 0 && ( - {figures.info} + {figures.info} No snapshots found. Try: - + rln snapshot create <devbox-id> @@ -142,22 +143,22 @@ const ListSnapshotsUI: React.FC<{ {!loading && !error && snapshots.length > 0 && ( <> - + {figures.tick} {ready} - + {figures.ellipsis} {pending} - + {figures.hamburger} {snapshots.length} {snapshots.length >= MAX_FETCH && '+'} {totalPages > 1 && ( <> - - + + Page {currentPage + 1}/{totalPages} @@ -179,7 +180,7 @@ const ListSnapshotsUI: React.FC<{ 'id', 'ID', (snapshot: any) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), - { width: showFullId ? idWidth : 15, color: 'gray', dimColor: true, bold: false } + { width: showFullId ? idWidth : 15, color: colors.textDim, dimColor: true, bold: false } ), createTextColumn( 'name', @@ -191,30 +192,30 @@ const ListSnapshotsUI: React.FC<{ 'devbox', 'Devbox', (snapshot: any) => snapshot.devbox_id || '', - { width: devboxWidth, color: 'cyan', dimColor: true, bold: false, visible: showDevboxId } + { width: devboxWidth, color: colors.primary, dimColor: true, bold: false, visible: showDevboxId } ), createTextColumn( 'created', 'Created', (snapshot: any) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', - { width: timeWidth, color: 'gray', dimColor: true, bold: false } + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} /> - + {figures.arrowUp} {figures.arrowDown} Navigate • {totalPages > 1 && ( - + {' '} {figures.arrowLeft} {figures.arrowRight} Page • )} - + {' '} [Esc] Back diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index 511904c3..962b51e5 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; import chalk from 'chalk'; +import { colors } from '../utils/theme.js'; interface ActionsPopupProps { devbox: any; @@ -37,7 +38,7 @@ export const ActionsPopup: React.FC = ({ // Render all lines with background const lines = [ - bgLine(chalk.cyan.bold(` ${figures.play} Quick Actions`)), + bgLine(chalk.hex(colors.primary).bold(` ${figures.play} Quick Actions`)), chalk.bgBlack(' '.repeat(contentWidth)), ...operations.map((op, index) => { const isSelected = index === selectedOperation; @@ -47,28 +48,28 @@ export const ActionsPopup: React.FC = ({ let styled: string; if (isSelected) { const colorFn = chalk[op.color as 'red' | 'green' | 'blue' | 'yellow' | 'magenta' | 'cyan']; - styled = typeof colorFn === 'function' ? colorFn.bold(content) : chalk.white.bold(content); + styled = typeof colorFn === 'function' ? colorFn.bold(content) : chalk.hex(colors.text).bold(content); } else { - styled = chalk.gray(content); + styled = chalk.hex(colors.textDim)(content); } return bgLine(styled); }), chalk.bgBlack(' '.repeat(contentWidth)), - bgLine(chalk.gray.dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)), - bgLine(chalk.gray.dim(` [Esc] Close`)), + bgLine(chalk.hex(colors.textDim).dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)), + bgLine(chalk.hex(colors.textDim).dim(` [Esc] Close`)), ]; // Draw custom border with background to fill gaps - const borderTop = chalk.cyan('╭' + '─'.repeat(contentWidth) + '╮'); - const borderBottom = chalk.cyan('╰' + '─'.repeat(contentWidth) + '╯'); + const borderTop = chalk.hex(colors.primary)('╭' + '─'.repeat(contentWidth) + '╮'); + const borderBottom = chalk.hex(colors.primary)('╰' + '─'.repeat(contentWidth) + '╯'); return ( {borderTop} {lines.map((line, i) => ( - {chalk.cyan('│')}{line}{chalk.cyan('│')} + {chalk.hex(colors.primary)('│')}{line}{chalk.hex(colors.primary)('│')} ))} {borderBottom} diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 209465b9..f23d0f13 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { colors } from '../utils/theme.js'; interface BreadcrumbItem { label: string; @@ -16,17 +17,17 @@ export const Breadcrumb: React.FC = React.memo(({ items }) => { return ( - - rl + + rl {isDevEnvironment && (dev)} - + {items.map((item, index) => ( - + {item.label} {index < items.length - 1 && ( - + )} ))} diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 64edb2e6..9245103c 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface DetailSection { title: string; @@ -24,12 +25,12 @@ export const DetailView: React.FC = ({ sections }) => { {sections.map((section, sectionIndex) => ( - + {section.title} {section.items.map((item, itemIndex) => ( - + {item.label}: {item.value} diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 71ab78f8..4f36ccc6 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -9,6 +9,7 @@ import { ErrorMessage } from './ErrorMessage.js'; import { SuccessMessage } from './SuccessMessage.js'; import { Breadcrumb } from './Breadcrumb.js'; import type { SSHSessionConfig } from '../utils/sshSession.js'; +import { colors } from '../utils/theme.js'; type Operation = 'exec' | 'upload' | 'snapshot' | 'ssh' | 'logs' | 'tunnel' | 'suspend' | 'resume' | 'delete' | null; @@ -48,15 +49,15 @@ export const DevboxActionsMenu: React.FC = ({ const [copyStatus, setCopyStatus] = React.useState(null); const allOperations = [ - { key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' }, + { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, + { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, + { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, + { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, + { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, + { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, + { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, + { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, + { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, ]; // Filter operations based on devbox status @@ -423,34 +424,34 @@ export const DevboxActionsMenu: React.FC = ({ const hasMore = actualScroll + viewportHeight < allLines.length; const hasLess = actualScroll > 0; - const exitCodeColor = exitCode === 0 ? 'green' : 'red'; + const exitCodeColor = exitCode === 0 ? colors.success : colors.error; return ( <> {/* Command header */} - + - {figures.play} Command: + {figures.play} Command: - {command} + {command} - Exit Code: + Exit Code: {exitCode} {/* Output display */} - + {allLines.length === 0 && ( - No output + No output )} {visibleLines.map((line: string, index: number) => { const actualIndex = actualScroll + index; const isStderr = actualIndex >= stdoutLines.length; - const lineColor = isStderr ? 'red' : 'white'; + const lineColor = isStderr ? colors.error : colors.text; return ( @@ -461,53 +462,53 @@ export const DevboxActionsMenu: React.FC = ({ {hasLess && ( - {figures.arrowUp} More above + {figures.arrowUp} More above )} {hasMore && ( - {figures.arrowDown} More below + {figures.arrowDown} More below )} {/* Statistics bar */} - + {figures.hamburger} {allLines.length} - lines + lines {allLines.length > 0 && ( <> - - + + Viewing {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, allLines.length)} of {allLines.length} )} {stdout && ( <> - - stdout: {stdoutLines.length} lines + + stdout: {stdoutLines.length} lines )} {stderr && ( <> - - stderr: {stderrLines.length} lines + + stderr: {stderrLines.length} lines )} {copyStatus && ( <> - - {copyStatus} + + {copyStatus} )} {/* Help bar */} - + {figures.arrowUp}{figures.arrowDown} Navigate • [g] Top • [G] Bottom • [c] Copy • [Enter], [q], or [esc] Back @@ -533,7 +534,7 @@ export const DevboxActionsMenu: React.FC = ({ <> - + {visibleLogs.map((log: any, index: number) => { const time = new Date(log.timestamp_ms).toLocaleTimeString(); const level = log.level ? log.level[0].toUpperCase() : 'I'; @@ -542,21 +543,21 @@ export const DevboxActionsMenu: React.FC = ({ const cmd = log.cmd ? `[${log.cmd.substring(0, 40)}${log.cmd.length > 40 ? '...' : ''}] ` : ''; const exitCode = log.exit_code !== null && log.exit_code !== undefined ? `(${log.exit_code}) ` : ''; - let levelColor = 'gray'; - if (level === 'E') levelColor = 'red'; - else if (level === 'W') levelColor = 'yellow'; - else if (level === 'I') levelColor = 'cyan'; + let levelColor: string = colors.textDim; + if (level === 'E') levelColor = colors.error; + else if (level === 'W') levelColor = colors.warning; + else if (level === 'I') levelColor = colors.primary; if (logsWrapMode) { return ( - {time} + {time} {level} - /{source} + /{source} - {exitCode && {exitCode}} - {cmd && {cmd}} + {exitCode && {exitCode}} + {cmd && {cmd}} {fullMessage} ); @@ -568,13 +569,13 @@ export const DevboxActionsMenu: React.FC = ({ : fullMessage; return ( - {time} + {time} {level} - /{source} + /{source} - {exitCode && {exitCode}} - {cmd && {cmd}} + {exitCode && {exitCode}} + {cmd && {cmd}} {truncatedMessage} ); @@ -583,39 +584,39 @@ export const DevboxActionsMenu: React.FC = ({ {hasLess && ( - {figures.arrowUp} More above + {figures.arrowUp} More above )} {hasMore && ( - {figures.arrowDown} More below + {figures.arrowDown} More below )} - + {figures.hamburger} {totalCount} - total logs - - + total logs + + Viewing {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, logs.length)} of {logs.length} - - + + {logsWrapMode ? 'Wrap: ON' : 'Wrap: OFF'} {copyStatus && ( <> - - {copyStatus} + + {copyStatus} )} - + {figures.arrowUp}{figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle Wrap • [c] Copy • [Enter], [q], or [esc] Back @@ -630,7 +631,7 @@ export const DevboxActionsMenu: React.FC = ({ {operationResult && } {operationError && } - + Press [Enter], [q], or [esc] to continue @@ -686,12 +687,12 @@ export const DevboxActionsMenu: React.FC = ({
- + {devbox.name || devbox.id} - {prompts[executingOperation]} + {prompts[executingOperation]} = ({ /> - + Press [Enter] to execute • [q or esc] Cancel @@ -724,15 +725,15 @@ export const DevboxActionsMenu: React.FC = ({ <> - {figures.play} Operations + {figures.play} Operations {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( - {isSelected ? figures.pointer : ' '} - {op.icon} {op.label} - [{op.shortcut}] + {isSelected ? figures.pointer : ' '} + {op.icon} {op.label} + [{op.shortcut}] ); })} @@ -740,7 +741,7 @@ export const DevboxActionsMenu: React.FC = ({ - + {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Select • [q] Back diff --git a/src/components/DevboxCard.tsx b/src/components/DevboxCard.tsx index 5d4f9af9..f38049a3 100644 --- a/src/components/DevboxCard.tsx +++ b/src/components/DevboxCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface DevboxCardProps { id: string; @@ -19,17 +20,17 @@ export const DevboxCard: React.FC = ({ const getStatusDisplay = (status: string) => { switch (status) { case 'running': - return { icon: figures.tick, color: 'green' }; + return { icon: figures.tick, color: colors.success }; case 'provisioning': case 'initializing': - return { icon: figures.ellipsis, color: 'yellow' }; + return { icon: figures.ellipsis, color: colors.warning }; case 'stopped': case 'suspended': - return { icon: figures.circle, color: 'gray' }; + return { icon: figures.circle, color: colors.textDim }; case 'failed': - return { icon: figures.cross, color: 'red' }; + return { icon: figures.cross, color: colors.error }; default: - return { icon: figures.circle, color: 'gray' }; + return { icon: figures.circle, color: colors.textDim }; } }; @@ -41,17 +42,17 @@ export const DevboxCard: React.FC = ({ {statusDisplay.icon} - + {displayName.slice(0, 18)} - + {id} {createdAt && ( <> - + {new Date(createdAt).toLocaleDateString()} diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 2fa5f3de..dbfbd89e 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -9,6 +9,7 @@ import { ErrorMessage } from './ErrorMessage.js'; import { SuccessMessage } from './SuccessMessage.js'; import { Breadcrumb } from './Breadcrumb.js'; import { MetadataDisplay } from './MetadataDisplay.js'; +import { colors } from '../utils/theme.js'; interface DevboxCreatePageProps { onBack: () => void; @@ -388,7 +389,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr details={`ID: ${result.id}\nName: ${result.name || '(none)'}\nStatus: ${result.status}`} /> - + Press [Enter], [q], or [esc] to return to list @@ -406,7 +407,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr ]} /> - + Press [Enter] or [r] to retry • [q] or [esc] to cancel @@ -443,11 +444,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr if (field.type === 'action') { return ( - + {isActive ? figures.pointer : ' '} {field.label} {isActive && ( - + {' '}[Enter to create] )} @@ -458,7 +459,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr if (field.type === 'text') { return ( - + {isActive ? figures.pointer : ' '} {field.label}:{' '} {isActive ? ( @@ -476,7 +477,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr } /> ) : ( - {String(fieldData || '(empty)')} + {String(fieldData || '(empty)')} )} ); @@ -486,14 +487,14 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const value = fieldData as string; return ( - + {isActive ? figures.pointer : ' '} {field.label}: - + {' '}{value || '(none)'} {isActive && ( - + {' '}[{figures.arrowLeft}{figures.arrowRight} to change] )} @@ -507,12 +508,12 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr return ( - + {isActive ? figures.pointer : ' '} {field.label}:{' '} - {Object.keys(formData.metadata).length} item(s) + {Object.keys(formData.metadata).length} item(s) {isActive && ( - [Enter to manage] + [Enter to manage] )} {Object.keys(formData.metadata).length > 0 && ( @@ -530,19 +531,19 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const maxIndex = metadataKeys.length + 1; return ( - - {figures.hamburger} Manage Metadata + + {figures.hamburger} Manage Metadata {/* Input form - shown when adding or editing */} {metadataInputMode && ( - - + + {selectedMetadataIndex === 0 ? 'Adding New' : 'Editing'} {metadataInputMode === 'key' ? ( <> - Key: + Key: ) : ( @@ -552,7 +553,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {metadataInputMode === 'value' ? ( <> - Value: + Value: ) : ( @@ -567,10 +568,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr <> {/* Add new option */} - + {selectedMetadataIndex === 0 ? figures.pointer : ' '}{' '} - + + Add new metadata @@ -583,10 +584,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const isSelected = selectedMetadataIndex === itemIndex; return ( - + {isSelected ? figures.pointer : ' '}{' '} - + {key}: {formData.metadata[key]} @@ -597,10 +598,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {/* Done option */} - + {selectedMetadataIndex === maxIndex ? figures.pointer : ' '}{' '} - + {figures.tick} Done @@ -608,8 +609,8 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr )} {/* Help text */} - - + + {metadataInputMode ? `[Tab] Switch field • [Enter] ${metadataInputMode === 'key' ? 'Next' : 'Save'} • [esc] Cancel` : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMetadataIndex === 0 ? 'Add' : selectedMetadataIndex === maxIndex ? 'Done' : 'Edit'} • [d] Delete • [esc] Back` @@ -627,15 +628,15 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {/* Validation warning */} {formData.resource_size === 'CUSTOM_SIZE' && validateCustomResources() && ( - - {figures.cross} Validation Error - {validateCustomResources()} + + {figures.cross} Validation Error + {validateCustomResources()} )} {!inMetadataSection && ( - + {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Create • [q] Cancel diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 12fdd700..17c49cae 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -8,6 +8,7 @@ import { Breadcrumb } from './Breadcrumb.js'; import { DevboxActionsMenu } from './DevboxActionsMenu.js'; import { getDevboxUrl } from '../utils/url.js'; import type { SSHSessionConfig } from '../utils/sshSession.js'; +import { colors } from '../utils/theme.js'; interface DevboxDetailPageProps { devbox: any; @@ -47,15 +48,15 @@ export const DevboxDetailPage: React.FC = ({ devbox: init const selectedDevbox = initialDevbox; const allOperations = [ - { key: 'logs', label: 'View Logs', color: 'blue', icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: 'green', icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: 'green', icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: 'yellow', icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: 'cyan', icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: 'magenta', icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: 'yellow', icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' }, + { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, + { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, + { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, + { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, + { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, + { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, + { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, + { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, + { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, ]; // Filter operations based on devbox status @@ -176,7 +177,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); // Core Information - lines.push(Devbox Details); + lines.push(Devbox Details); lines.push( ID: {selectedDevbox.id}); lines.push( Name: {selectedDevbox.name || '(none)'}); lines.push( Status: {capitalize(selectedDevbox.status)}); @@ -188,7 +189,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Capabilities if (selectedDevbox.capabilities && selectedDevbox.capabilities.length > 0) { - lines.push(Capabilities); + lines.push(Capabilities); selectedDevbox.capabilities.forEach((cap: string, idx: number) => { lines.push( {figures.pointer} {cap}); }); @@ -197,7 +198,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Launch Parameters if (selectedDevbox.launch_parameters) { - lines.push(Launch Parameters); + lines.push(Launch Parameters); const lp = selectedDevbox.launch_parameters; @@ -248,7 +249,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Source if (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) { - lines.push(Source); + lines.push(Source); if (selectedDevbox.blueprint_id) { lines.push( Blueprint: {selectedDevbox.blueprint_id}); } @@ -260,7 +261,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Initiator if (selectedDevbox.initiator_type) { - lines.push(Initiator); + lines.push(Initiator); lines.push( Type: {selectedDevbox.initiator_type}); if (selectedDevbox.initiator_id) { lines.push( ID: {selectedDevbox.initiator_id}); @@ -270,9 +271,9 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Status Details if (selectedDevbox.failure_reason || selectedDevbox.shutdown_reason) { - lines.push(Status Details); + lines.push(Status Details); if (selectedDevbox.failure_reason) { - lines.push( Failure Reason: {selectedDevbox.failure_reason}); + lines.push( Failure Reason: {selectedDevbox.failure_reason}); } if (selectedDevbox.shutdown_reason) { lines.push( Shutdown Reason: {selectedDevbox.shutdown_reason}); @@ -282,7 +283,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Metadata if (selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0) { - lines.push(Metadata); + lines.push(Metadata); Object.entries(selectedDevbox.metadata).forEach(([key, value], idx) => { lines.push( {key}: {value as string}); }); @@ -291,7 +292,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // State Transitions if (selectedDevbox.state_transitions && selectedDevbox.state_transitions.length > 0) { - lines.push(State History); + lines.push(State History); selectedDevbox.state_transitions.forEach((transition: any, idx: number) => { const text = `${idx + 1}. ${capitalize(transition.status)}${transition.transition_time_ms ? ` at ${new Date(transition.transition_time_ms).toLocaleString()}` : ''}`; lines.push( {text}); @@ -300,7 +301,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init } // Raw JSON (full) - lines.push(Raw JSON); + lines.push(Raw JSON); const jsonLines = JSON.stringify(selectedDevbox, null, 2).split('\n'); jsonLines.forEach((line, idx) => { lines.push( {line}); @@ -355,7 +356,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init - {selectedDevbox.id} + {selectedDevbox.id} @@ -364,7 +365,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init marginTop={1} marginBottom={1} borderStyle="round" - borderColor="gray" + borderColor={colors.border} paddingX={2} paddingY={1} > @@ -373,18 +374,18 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {hasLess && ( - {figures.arrowUp} More above + {figures.arrowUp} More above )} {hasMore && ( - {figures.arrowDown} More below + {figures.arrowDown} More below )} - + {figures.arrowUp} {figures.arrowDown} Scroll • [q or esc] Back to Details • Line {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, detailLines.length)} of {detailLines.length} @@ -406,22 +407,22 @@ export const DevboxDetailPage: React.FC = ({ devbox: init
{/* Compact info section */} - + - {selectedDevbox.name || selectedDevbox.id} + {selectedDevbox.name || selectedDevbox.id} - • {selectedDevbox.id} + • {selectedDevbox.id} - {formattedCreateTime} - ({createTimeAgo}) + {formattedCreateTime} + ({createTimeAgo}) {uptime !== null && selectedDevbox.status === 'running' && ( - Uptime: {uptime < 60 ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} + Uptime: {uptime < 60 ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} {lp?.keep_alive_time_seconds && ( - • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m + • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m )} )} @@ -431,8 +432,8 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Resources */} {(lp?.resource_size_request || lp?.custom_cpu_cores || lp?.custom_gb_memory || lp?.custom_disk_size || lp?.architecture) && ( - - {figures.squareSmallFilled} Resources + + {figures.squareSmallFilled} Resources {lp?.resource_size_request && `${lp.resource_size_request}`} {lp?.architecture && ` • ${lp.architecture}`} @@ -445,16 +446,16 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Capabilities */} {hasCapabilities && ( - - {figures.tick} Capabilities + + {figures.tick} Capabilities {selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').join(', ')} )} {/* Source */} {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( - - {figures.circleFilled} Source + + {figures.circleFilled} Source {selectedDevbox.blueprint_id && `BP: ${selectedDevbox.blueprint_id}`} {selectedDevbox.snapshot_id && `Snap: ${selectedDevbox.snapshot_id}`} @@ -465,30 +466,30 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Metadata - compact */} {selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0 && ( - + )} {/* Failure - compact */} {selectedDevbox.failure_reason && ( - - {figures.cross} - {selectedDevbox.failure_reason} + + {figures.cross} + {selectedDevbox.failure_reason} )} {/* Operations - inline display */} - {figures.play} Actions + {figures.play} Actions {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( - {isSelected ? figures.pointer : ' '} - {op.icon} {op.label} - [{op.shortcut}] + {isSelected ? figures.pointer : ' '} + {op.icon} {op.label} + [{op.shortcut}] ); })} @@ -496,7 +497,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init - + {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Execute • [i] Full Details • [o] Browser • [q] Back diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index 669e208a..b7028092 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface ErrorMessageProps { message: string; @@ -11,13 +12,13 @@ export const ErrorMessage: React.FC = ({ message, error }) => return ( - + {figures.cross} {message} {error && ( - + {error.message} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0de3af11..41db5dc1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; +import { colors } from '../utils/theme.js'; interface HeaderProps { title: string; @@ -11,20 +12,20 @@ export const Header: React.FC = React.memo(({ title, subtitle }) => return ( - + ▌{title} {subtitle && ( <> - + {subtitle} )} - {'─'.repeat(title.length + 1)} + {'─'.repeat(title.length + 1)} ); diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index f73deb32..69651e2c 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -4,6 +4,7 @@ import figures from 'figures'; import { Banner } from './Banner.js'; import { Breadcrumb } from './Breadcrumb.js'; import { VERSION } from '../cli.js'; +import { colors } from '../utils/theme.js'; interface MenuItem { key: string; @@ -30,21 +31,21 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { label: 'Devboxes', description: 'Manage cloud development environments', icon: '◉', - color: 'cyan', + color: colors.accent1, }, { key: 'blueprints', label: 'Blueprints', description: 'Create and manage devbox templates', icon: '▣', - color: 'magenta', + color: colors.accent2, }, { key: 'snapshots', label: 'Snapshots', description: 'Save and restore devbox states', icon: '◈', - color: 'green', + color: colors.accent3, }, ], []); @@ -73,10 +74,10 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { return ( - + RUNLOOP.ai - + {' '} • Cloud development environments • v{VERSION} @@ -87,7 +88,7 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { const isSelected = index === selectedIndex; return ( - + {isSelected ? figures.pointer : ' '} @@ -95,14 +96,14 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { {item.icon} - + {item.label} - + {' '} - {item.description} - + {' '} [{index + 1}] @@ -112,7 +113,7 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { - + {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit @@ -124,21 +125,21 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { - + - + - + Cloud development environments for your team • v{VERSION} - - - + + + Select a resource: @@ -151,22 +152,23 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { paddingX={2} paddingY={0} borderStyle={isSelected ? 'round' : 'single'} - borderColor={isSelected ? item.color : 'gray'} - marginBottom={0} + borderColor={isSelected ? item.color : colors.border} + marginTop={index === 0 ? 1 : 0} + flexShrink={0} > {item.icon} - + {item.label} - - + + {item.description} - + [{index + 1}] @@ -174,9 +176,9 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { })} - + - + {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index 1e1b3b2a..57af2d56 100644 --- a/src/components/MetadataDisplay.tsx +++ b/src/components/MetadataDisplay.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { Badge } from '@inkjs/ui'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface MetadataDisplayProps { metadata: Record; @@ -12,8 +13,8 @@ interface MetadataDisplayProps { // Generate color for each key based on hash const getColorForKey = (key: string, index: number): string => { - const colors = ['cyan', 'magenta', 'yellow', 'blue', 'green', 'red']; - return colors[index % colors.length]; + const colorList = [colors.primary, colors.secondary, colors.warning, colors.info, colors.success, colors.error]; + return colorList[index % colorList.length]; }; export const MetadataDisplay: React.FC = ({ @@ -32,7 +33,7 @@ export const MetadataDisplay: React.FC = ({ {title && ( <> - + {figures.info} {title} @@ -44,9 +45,9 @@ export const MetadataDisplay: React.FC = ({ return ( {isSelected && ( - {figures.pointer} + {figures.pointer} )} - + {`${key}: ${value}`} @@ -59,7 +60,7 @@ export const MetadataDisplay: React.FC = ({ return ( = ({ <> {/* Operations List */} - + {figures.play} Operations @@ -49,10 +50,10 @@ export const OperationsMenu: React.FC = ({ const isSelected = index === selectedIndex; return ( - + {isSelected ? figures.pointer : ' '}{' '} - + {op.icon} {op.label} @@ -63,7 +64,7 @@ export const OperationsMenu: React.FC = ({ {/* Help text */} - + {figures.arrowUp} {figures.arrowDown} Navigate • [Enter] Select • {additionalActions.map((action) => ` [${action.key}] ${action.label} •`)} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 7d4bbaca..97b4f5e5 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; +import { colors } from '../utils/theme.js'; interface SpinnerComponentProps { message: string; @@ -9,7 +10,7 @@ interface SpinnerComponentProps { export const SpinnerComponent: React.FC = ({ message }) => { return ( - + {message} diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index be16c53c..093471b3 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface StatusBadgeProps { status: string; @@ -15,40 +16,40 @@ export interface StatusDisplay { export const getStatusDisplay = (status: string): StatusDisplay => { if (!status) { - return { icon: figures.questionMarkPrefix, color: 'gray', text: 'UNKNOWN' }; + return { icon: figures.questionMarkPrefix, color: colors.textDim, text: 'UNKNOWN' }; } switch (status) { case 'running': - return { icon: figures.circleFilled, color: 'green', text: 'RUNNING ' }; + return { icon: figures.circleFilled, color: colors.success, text: 'RUNNING ' }; case 'provisioning': - return { icon: figures.ellipsis, color: 'yellow', text: 'PROVISION ' }; + return { icon: figures.ellipsis, color: colors.warning, text: 'PROVISION ' }; case 'initializing': - return { icon: figures.ellipsis, color: 'cyan', text: 'INITIALIZE' }; + return { icon: figures.ellipsis, color: colors.primary, text: 'INITIALIZE' }; case 'suspended': - return { icon: figures.circleDotted, color: 'yellow', text: 'SUSPENDED ' }; + return { icon: figures.circleDotted, color: colors.warning, text: 'SUSPENDED ' }; case 'failure': - return { icon: figures.cross, color: 'red', text: 'FAILED ' }; + return { icon: figures.cross, color: colors.error, text: 'FAILED ' }; case 'shutdown': - return { icon: figures.circle, color: 'gray', text: 'SHUTDOWN ' }; + return { icon: figures.circle, color: colors.textDim, text: 'SHUTDOWN ' }; case 'resuming': - return { icon: figures.ellipsis, color: 'cyan', text: 'RESUMING ' }; + return { icon: figures.ellipsis, color: colors.primary, text: 'RESUMING ' }; case 'suspending': - return { icon: figures.ellipsis, color: 'yellow', text: 'SUSPENDING' }; + return { icon: figures.ellipsis, color: colors.warning, text: 'SUSPENDING' }; case 'ready': - return { icon: figures.tick, color: 'green', text: 'READY ' }; + return { icon: figures.tick, color: colors.success, text: 'READY ' }; case 'build_complete': case 'building_complete': - return { icon: figures.tick, color: 'green', text: 'COMPLETE ' }; + return { icon: figures.tick, color: colors.success, text: 'COMPLETE ' }; case 'building': - return { icon: figures.ellipsis, color: 'yellow', text: 'BUILDING ' }; + return { icon: figures.ellipsis, color: colors.warning, text: 'BUILDING ' }; case 'build_failed': - return { icon: figures.cross, color: 'red', text: 'FAILED ' }; + return { icon: figures.cross, color: colors.error, text: 'FAILED ' }; default: // Truncate and pad any unknown status to 10 chars to match column width const truncated = status.toUpperCase().slice(0, 10); const padded = truncated.padEnd(10, ' '); - return { icon: figures.questionMarkPrefix, color: 'gray', text: padded }; + return { icon: figures.questionMarkPrefix, color: colors.textDim, text: padded }; } }; diff --git a/src/components/SuccessMessage.tsx b/src/components/SuccessMessage.tsx index 3210cc0c..d133d6cf 100644 --- a/src/components/SuccessMessage.tsx +++ b/src/components/SuccessMessage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; interface SuccessMessageProps { message: string; @@ -14,14 +15,14 @@ export const SuccessMessage: React.FC = ({ return ( - + {figures.tick} {message} {details && ( {details.split('\n').map((line, i) => ( - + {line} ))} diff --git a/src/components/Table.example.tsx b/src/components/Table.example.tsx index 53ef3146..9317b186 100644 --- a/src/components/Table.example.tsx +++ b/src/components/Table.example.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { Table, createTextColumn, createComponentColumn } from './Table.js'; import { StatusBadge } from './StatusBadge.js'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; // ============================================================================ // EXAMPLE 1: Blueprints Table @@ -53,7 +54,7 @@ function BlueprintsTable({ 'id', 'ID', (bp) => showFullId ? bp.id : bp.id.slice(0, 13), - { width: showFullId ? 25 : 15, color: 'gray', dimColor: true, bold: false } + { width: showFullId ? 25 : 15, color: colors.textDim, dimColor: true, bold: false } ), // Name column createTextColumn( @@ -67,19 +68,19 @@ function BlueprintsTable({ 'description', 'Description', (bp) => bp.description || '', - { width: 40, color: 'gray', dimColor: true, bold: false, visible: showDescription } + { width: 40, color: colors.textDim, dimColor: true, bold: false, visible: showDescription } ), // Created time column createTextColumn( 'created', 'Created', (bp) => new Date(bp.created_at).toLocaleDateString(), - { width: 15, color: 'gray', dimColor: true, bold: false } + { width: 15, color: colors.textDim, dimColor: true, bold: false } ), ]} emptyState={ - {figures.info} No blueprints found + {figures.info} No blueprints found } /> @@ -130,7 +131,7 @@ function SnapshotsTable({ 'id', 'ID', (snap) => showFullId ? snap.id : snap.id.slice(0, 13), - { width: showFullId ? 25 : 15, color: 'gray', dimColor: true, bold: false } + { width: showFullId ? 25 : 15, color: colors.textDim, dimColor: true, bold: false } ), // Name column createTextColumn( @@ -144,26 +145,26 @@ function SnapshotsTable({ 'devbox', 'Devbox', (snap) => snap.devbox_id.slice(0, 13), - { width: 15, color: 'cyan', dimColor: true, bold: false } + { width: 15, color: colors.primary, dimColor: true, bold: false } ), // Size column (optional) createTextColumn( 'size', 'Size', (snap) => snap.size_gb ? `${snap.size_gb.toFixed(1)}GB` : '', - { width: 10, color: 'yellow', dimColor: true, bold: false, visible: showSize } + { width: 10, color: colors.warning, dimColor: true, bold: false, visible: showSize } ), // Created time column createTextColumn( 'created', 'Created', (snap) => new Date(snap.created_at).toLocaleDateString(), - { width: 15, color: 'gray', dimColor: true, bold: false } + { width: 15, color: colors.textDim, dimColor: true, bold: false } ), ]} emptyState={ - {figures.info} No snapshots found + {figures.info} No snapshots found } /> @@ -204,7 +205,7 @@ function CustomComplexColumn() { 'Tags', (item, index, isSelected) => ( - + {item.tags.map(tag => `[${tag}]`).join(' ')} diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 510d61c1..7e76430e 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import figures from 'figures'; +import { colors } from '../utils/theme.js'; export interface Column { /** Column key for identification */ @@ -57,11 +58,11 @@ export function Table({ {/* Title bar (if provided) */} {title && ( - ╭─ {title} {'─'.repeat(Math.max(0, 10))}╮ + ╭─ {title} {'─'.repeat(Math.max(0, 10))}╮ )} - + {/* Header row */} {/* Space for selection pointer */} @@ -90,7 +91,7 @@ export function Table({ {/* Selection pointer */} {showSelection && ( <> - + {isSelected ? figures.pointer : ' '} @@ -134,7 +135,7 @@ export function createTextColumn( render: (row, index, isSelected) => { const value = getValue(row); const width = options?.width || 20; - const color = options?.color || (isSelected ? 'white' : 'white'); + const color = options?.color || (isSelected ? colors.text : colors.text); const bold = options?.bold !== undefined ? options.bold : isSelected; const dimColor = options?.dimColor || false; @@ -143,7 +144,7 @@ export function createTextColumn( const padded = truncated.padEnd(width, ' '); return ( - + {padded} ); diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 00000000..852cf5a3 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,29 @@ +/** + * Color theme constants for the CLI application + * Centralized color definitions for easy theme customization + */ + +export const colors = { + // Primary brand colors + primary: 'cyan', + secondary: 'magenta', + + // Status colors + success: 'green', + warning: 'yellow', + error: 'red', + info: 'blue', + + // UI colors + text: 'white', + textDim: 'gray', + border: 'gray', + + // Accent colors for menu items and highlights + accent1: 'cyan', + accent2: 'magenta', + accent3: 'green', +} as const; + +export type ColorName = keyof typeof colors; +export type ColorValue = (typeof colors)[ColorName]; From d47617242f5ce615e1f48c848b1e8ce15d93a589 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 14:26:22 -0700 Subject: [PATCH 13/25] cp dines --- CLAUDE_SETUP.md | 200 +++++++ MCP_COMMANDS.md | 163 ++++++ MCP_README.md | 188 +++++++ README.md | 35 ++ package-lock.json | 1041 ++++++++++++++++++++++++++++++++++- package.json | 3 + src/cli.ts | 47 +- src/commands/mcp-http.ts | 45 ++ src/commands/mcp-install.ts | 129 +++++ src/commands/mcp.ts | 36 ++ src/mcp/server-http.ts | 434 +++++++++++++++ src/mcp/server.ts | 411 ++++++++++++++ 12 files changed, 2727 insertions(+), 5 deletions(-) create mode 100644 CLAUDE_SETUP.md create mode 100644 MCP_COMMANDS.md create mode 100644 MCP_README.md create mode 100644 src/commands/mcp-http.ts create mode 100644 src/commands/mcp-install.ts create mode 100644 src/commands/mcp.ts create mode 100644 src/mcp/server-http.ts create mode 100644 src/mcp/server.ts diff --git a/CLAUDE_SETUP.md b/CLAUDE_SETUP.md new file mode 100644 index 00000000..92b952b5 --- /dev/null +++ b/CLAUDE_SETUP.md @@ -0,0 +1,200 @@ +# Setting Up Runloop MCP with Claude + +This guide will walk you through connecting the Runloop MCP server to Claude Desktop. + +## Prerequisites + +1. Make sure you have Claude Desktop installed +2. Authenticate with Runloop: `rln auth` +3. Make sure `rln` is installed globally and in your PATH + +## Quick Setup (Automatic) + +The easiest way to set up Runloop with Claude Desktop: + +```bash +rln mcp install +``` + +This command will: +- Automatically find your Claude Desktop configuration file +- Add the Runloop MCP server configuration +- Preserve any existing MCP servers you have configured + +Then just restart Claude Desktop and you're ready to go! + +## Manual Setup + +If you prefer to set it up manually or the automatic install doesn't work: + +### 1. Find Your Claude Configuration File + +The location depends on your operating system: + +**macOS:** +``` +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +**Windows:** +``` +%APPDATA%\Claude\claude_desktop_config.json +``` + +**Linux:** +``` +~/.config/Claude/claude_desktop_config.json +``` + +### 2. Edit the Configuration File + +If the file doesn't exist, create it. Add or update it with this configuration: + +```json +{ + "mcpServers": { + "runloop": { + "command": "rln", + "args": ["mcp", "start"] + } + } +} +``` + +**If you already have other MCP servers configured**, just add the runloop entry to the existing `mcpServers` object: + +```json +{ + "mcpServers": { + "existing-server": { + "command": "some-command", + "args": ["some-args"] + }, + "runloop": { + "command": "rln", + "args": ["mcp", "start"] + } + } +} +``` + +### 3. Restart Claude Desktop + +Close and reopen Claude Desktop completely for the changes to take effect. + +### 4. Verify It's Working + +In Claude Desktop, you should now be able to ask Claude to interact with your Runloop account: + +**Try these example prompts:** + +- "Can you list all my devboxes?" +- "Show me my running devboxes" +- "Create a new devbox called 'test-env'" +- "What blueprints are available?" +- "Execute 'pwd' on devbox [your-devbox-id]" + +Claude will now have access to these Runloop tools and can manage your devboxes! + +## Troubleshooting + +### "Command not found: rln" + +Make sure `rln` is in your PATH. Test by running `which rln` (macOS/Linux) or `where rln` (Windows) in your terminal. + +If not found: +- If installed via npm globally: `npm install -g @runloop/rl-cli` +- Check your npm global bin directory is in PATH: `npm config get prefix` + +### "API key not configured" + +Run `rln auth` to configure your API key before using the MCP server. + +### Claude doesn't show Runloop tools + +1. Make sure you saved the config file correctly (valid JSON) +2. Restart Claude Desktop completely (quit, not just close window) +3. Check Claude's developer logs for errors: + - **macOS:** `~/Library/Logs/Claude/` + - **Windows:** `%APPDATA%\Claude\logs\` + +### Testing the MCP server manually + +You can test if the MCP server is working by running: + +```bash +rln mcp start +``` + +It should output: `Runloop MCP server running on stdio` + +Press Ctrl+C to stop it. + +## Advanced Configuration + +### Using Development Environment + +If you want to connect to Runloop's development environment: + +```json +{ + "mcpServers": { + "runloop": { + "command": "rln", + "args": ["mcp", "start"], + "env": { + "RUNLOOP_ENV": "dev" + } + } + } +} +``` + +### Using a Specific Path + +If `rln` isn't in your PATH, you can specify the full path: + +```json +{ + "mcpServers": { + "runloop": { + "command": "/full/path/to/rln", + "args": ["mcp", "start"] + } + } +} +``` + +Find the full path with: `which rln` (macOS/Linux) or `where rln` (Windows) + +## What Can Claude Do Now? + +Once connected, Claude can: + +- ✅ List all your devboxes +- ✅ Get detailed information about specific devboxes +- ✅ Create new devboxes +- ✅ Execute commands on devboxes +- ✅ Shutdown, suspend, and resume devboxes +- ✅ List available blueprints +- ✅ List and create snapshots + +Claude will automatically use these tools when you ask questions about your Runloop infrastructure! + +## Example Conversation + +**You:** "What devboxes do I have running right now?" + +**Claude:** *Uses the list_devboxes tool and shows you all running devboxes with their details* + +**You:** "Create a new devbox called 'api-server' using the python-base blueprint" + +**Claude:** *Uses the create_devbox tool with the specified parameters and confirms creation* + +**You:** "Run 'python --version' on that new devbox" + +**Claude:** *Uses the execute_command tool and shows you the output* + +--- + +**Need help?** Open an issue at https://github.com/runloop/rl-cli-node/issues diff --git a/MCP_COMMANDS.md b/MCP_COMMANDS.md new file mode 100644 index 00000000..443c7609 --- /dev/null +++ b/MCP_COMMANDS.md @@ -0,0 +1,163 @@ +# Runloop MCP Commands Reference + +Quick reference for all Runloop MCP commands. + +## Installation + +### Install MCP in Claude Desktop + +Automatically configure Claude Desktop to use Runloop MCP: + +```bash +rln mcp install +``` + +This command: +- Finds your Claude Desktop config file automatically +- Adds Runloop MCP server configuration +- Preserves existing MCP servers +- Prompts before overwriting existing Runloop configuration + +After running this, restart Claude Desktop. + +## Starting the MCP Server + +### Stdio Mode (for Claude Desktop) + +Start the MCP server in stdio mode: + +```bash +rln mcp start +``` + +This mode is used by Claude Desktop and other local AI assistants. The server communicates via stdin/stdout. + +### HTTP Mode (for remote/web access) + +Start the MCP server in HTTP mode: + +```bash +rln mcp start --http +``` + +This starts an HTTP server on port 3000 by default, using Server-Sent Events (SSE) for communication. + +### HTTP Mode with Custom Port + +Start the HTTP server on a specific port: + +```bash +rln mcp start --http --port 8080 +``` + +## Configuration File Format + +When you run `rln mcp install`, it creates this configuration in your Claude Desktop config: + +```json +{ + "mcpServers": { + "runloop": { + "command": "rln", + "args": ["mcp", "start"] + } + } +} +``` + +### With Environment Variables + +To use the development environment: + +```json +{ + "mcpServers": { + "runloop": { + "command": "rln", + "args": ["mcp", "start"], + "env": { + "RUNLOOP_ENV": "dev" + } + } + } +} +``` + +## Available Tools + +Once configured, Claude can use these tools: + +### Devbox Management +- `list_devboxes` - List all devboxes +- `get_devbox` - Get devbox details +- `create_devbox` - Create a new devbox +- `execute_command` - Run commands on a devbox +- `shutdown_devbox` - Shutdown a devbox +- `suspend_devbox` - Suspend a devbox +- `resume_devbox` - Resume a devbox + +### Blueprint Management +- `list_blueprints` - List available blueprints + +### Snapshot Management +- `list_snapshots` - List all snapshots +- `create_snapshot` - Create a snapshot + +## Example Usage + +Once set up, you can ask Claude: + +``` +"List all my devboxes" +"Create a new devbox called 'test-server'" +"Execute 'python --version' on devbox abc123" +"What blueprints are available?" +"Create a snapshot of my devbox" +``` + +## Testing + +Test the stdio server manually: + +```bash +rln mcp start +``` + +You should see: `Runloop MCP server running on stdio` + +Test the HTTP server: + +```bash +rln mcp start --http +``` + +You should see: +``` +Runloop MCP HTTP server running on http://localhost:3000 +SSE endpoint: http://localhost:3000/sse +Message endpoint: http://localhost:3000/message +``` + +## Common Issues + +### Command not found + +If you get "command not found: rln": +- Install globally: `npm install -g @runloop/rl-cli` +- Check your PATH includes npm global bin directory + +### API key not configured + +Run `rln auth` before using the MCP server. + +### Port already in use + +For HTTP mode, use a different port: +```bash +rln mcp start --http --port 8080 +``` + +## See Also + +- [CLAUDE_SETUP.md](./CLAUDE_SETUP.md) - Detailed setup guide for Claude Desktop +- [MCP_README.md](./MCP_README.md) - Complete MCP documentation diff --git a/MCP_README.md b/MCP_README.md new file mode 100644 index 00000000..8f5df8f8 --- /dev/null +++ b/MCP_README.md @@ -0,0 +1,188 @@ +# Runloop MCP Server + +This CLI includes a Model Context Protocol (MCP) server that allows AI assistants like Claude to interact with your Runloop devboxes programmatically. + +## What is MCP? + +The Model Context Protocol (MCP) is an open standard that enables AI applications to connect with external systems. It's like a USB-C port for AI applications - a standardized way to expose tools, resources, and context to large language models. + +## Starting the MCP Server + +Runloop provides two transport modes for the MCP server: + +### Stdio Mode (Default - Local Usage) + +For local AI assistants like Claude Desktop: + +```bash +rln mcp start +``` + +The server runs on stdio and communicates using the MCP protocol. + +### HTTP Mode (Remote Access) + +For web-based AI assistants or remote access: + +```bash +rln mcp start --http +``` + +Or specify a custom port: + +```bash +rln mcp start --http --port 8080 +``` + +The HTTP server runs on `http://localhost:3000` by default and uses Server-Sent Events (SSE) for communication. + +## Available Tools + +The MCP server exposes the following tools: + +### Devbox Management + +- **list_devboxes** - List all devboxes with optional status filtering + - Parameters: `status` (optional), `limit` (optional) + +- **get_devbox** - Get detailed information about a specific devbox + - Parameters: `id` (required) + +- **create_devbox** - Create a new devbox + - Parameters: `name`, `blueprint_id`, `snapshot_id`, `entrypoint`, `environment_variables`, `resource_size`, `keep_alive_seconds` + +- **execute_command** - Execute a command on a devbox + - Parameters: `devbox_id` (required), `command` (required) + +- **shutdown_devbox** - Shutdown a devbox + - Parameters: `id` (required) + +- **suspend_devbox** - Suspend a devbox + - Parameters: `id` (required) + +- **resume_devbox** - Resume a suspended devbox + - Parameters: `id` (required) + +### Blueprint Management + +- **list_blueprints** - List all available blueprints + - Parameters: `limit` (optional) + +### Snapshot Management + +- **list_snapshots** - List all snapshots + - Parameters: `devbox_id` (optional), `limit` (optional) + +- **create_snapshot** - Create a snapshot of a devbox + - Parameters: `devbox_id` (required), `name` (optional) + +## Configuration + +### Quick Install for Claude Desktop + +The easiest way to set up with Claude Desktop: + +```bash +rln mcp install +``` + +This automatically adds the configuration to your Claude Desktop config file and preserves any existing MCP servers. + +### Manual Configuration for Claude Desktop (Stdio) + +If you prefer to configure manually, add this to your Claude configuration file: + +**macOS/Linux:** Edit `~/Library/Application Support/Claude/claude_desktop_config.json` + +**Windows:** Edit `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "runloop": { + "command": "rln", + "args": ["mcp", "start"], + "env": { + "RUNLOOP_ENV": "prod" + } + } + } +} +``` + +### Configuring Web Clients (HTTP) + +For web-based AI clients, start the HTTP server and configure your client to connect to: + +- **SSE endpoint:** `http://localhost:3000/sse` +- **Message endpoint:** `http://localhost:3000/message` + +Example for Claude Code or other MCP clients supporting HTTP: + +```json +{ + "mcpServers": { + "runloop": { + "url": "http://localhost:3000/sse" + } + } +} +``` + +## Authentication + +The MCP server uses the same API key configuration as the CLI. Make sure you've authenticated first: + +```bash +rln auth +``` + +The server will automatically use your stored API credentials. + +## Example Usage with Claude + +Once configured, you can ask Claude to perform Runloop operations: + +- "List all my running devboxes" +- "Create a new devbox using the python-base blueprint" +- "Execute 'ls -la' on devbox abc123" +- "Show me all snapshots" +- "Create a snapshot of my devbox" + +Claude will use the MCP tools to interact with your Runloop account and provide responses based on the actual data. + +## Environment Variables + +- `RUNLOOP_ENV` - Set to `dev` for development environment, `prod` (or leave unset) for production +- API key is read from the CLI configuration (~/.config/rln/config.json) + +## Troubleshooting + +### Stdio Server + +If the stdio MCP server isn't working: + +1. Make sure you've run `rln auth` to configure your API key +2. Check that the `rln` command is in your PATH +3. Restart Claude Desktop after updating the configuration +4. Check Claude's logs for any error messages + +### HTTP Server + +If the HTTP MCP server isn't working: + +1. Make sure you've run `rln auth` to configure your API key +2. Check that the port isn't already in use +3. Verify the server is running: `curl http://localhost:3000/sse` +4. Check your firewall settings if connecting remotely +5. Look at the server logs for error messages + +### Common Issues + +- **"API key not configured"**: Run `rln auth` to set up your credentials +- **Port already in use**: Stop other services or use a different port with `--port` +- **Connection refused**: Make sure the server is running and accessible + +## Security Note + +The MCP server provides full access to your Runloop account. Only use it with trusted AI assistants, and be aware that the assistant can create, modify, and delete devboxes on your behalf. diff --git a/README.md b/README.md index cf7dc795..f388abbe 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A beautiful, interactive CLI for managing Runloop devboxes built with Ink and Ty - 🚀 Execute commands in devboxes - 📤 Upload files to devboxes - 🎯 Organized command structure with aliases +- 🤖 **Model Context Protocol (MCP) server for AI integration** ## Installation @@ -138,6 +139,40 @@ The CLI is organized into command buckets: - **`blueprint` (alias: `bp`)** - Manage blueprints - `list` - List blueprints (coming soon) +- **`mcp`** - Model Context Protocol server for AI integration + - `install` - Install MCP configuration in Claude Desktop + - `start` - Start the MCP server (stdio or HTTP mode) + +## MCP Server (AI Integration) + +Runloop includes a Model Context Protocol (MCP) server that allows AI assistants like Claude to interact with your devboxes. + +### Quick Setup for Claude Desktop + +```bash +# Install MCP configuration +rln mcp install + +# Restart Claude Desktop, then ask Claude: +# "List my devboxes" or "Create a new devbox" +``` + +### Starting the Server + +```bash +# Stdio mode (for Claude Desktop) +rln mcp start + +# HTTP mode (for web/remote access) +rln mcp start --http +rln mcp start --http --port 8080 +``` + +**Documentation:** +- [CLAUDE_SETUP.md](./CLAUDE_SETUP.md) - Complete setup guide for Claude Desktop +- [MCP_README.md](./MCP_README.md) - Full MCP documentation +- [MCP_COMMANDS.md](./MCP_COMMANDS.md) - Quick command reference + ## Interactive Features - **Pagination** - Lists show 10 items per page with keyboard navigation diff --git a/package-lock.json b/package-lock.json index 15b18b2f..7c77243a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,14 @@ "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.19.1", "@runloop/api-client": "^0.55.0", "@runloop/rl-cli": "^0.0.1", + "@types/express": "^5.0.3", "chalk": "^5.3.0", "commander": "^12.1.0", "conf": "^13.0.1", + "express": "^5.1.0", "figures": "^6.1.0", "gradient-string": "^2.0.2", "ink": "^5.0.1", @@ -81,6 +84,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", + "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/@runloop/api-client": { "version": "0.55.0", "resolved": "https://registry.npmjs.org/@runloop/api-client/-/api-client-0.55.0.tgz", @@ -142,6 +190,48 @@ "node": ">=18.0.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/gradient-string": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", @@ -151,6 +241,18 @@ "@types/tinycolor2": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.18.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.7.tgz", @@ -177,6 +279,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz", @@ -188,6 +302,36 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/tinycolor2": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", @@ -206,6 +350,40 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -317,6 +495,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -330,6 +537,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/cfonts": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/cfonts/-/cfonts-3.3.1.tgz", @@ -503,6 +726,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -512,6 +756,51 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -534,6 +823,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -564,6 +870,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -593,12 +908,27 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -678,6 +1008,12 @@ "benchmarks" ] }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -687,6 +1023,15 @@ "node": ">=8" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -696,12 +1041,117 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -733,6 +1183,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -768,6 +1235,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -942,6 +1427,31 @@ "node": ">= 0.4" } }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -951,6 +1461,18 @@ "ms": "^2.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -963,6 +1485,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ink": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", @@ -1104,9 +1632,18 @@ "react": ">=18" } }, - "node_modules/is-accessor-descriptor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-accessor-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", "license": "MIT", "dependencies": { @@ -1186,6 +1723,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -1198,6 +1741,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1249,6 +1798,27 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1297,6 +1867,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -1346,6 +1925,39 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -1361,6 +1973,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -1370,6 +1991,34 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1381,6 +2030,83 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1440,6 +2166,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1461,6 +2229,163 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1510,6 +2435,15 @@ "node": ">=10" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -1646,6 +2580,15 @@ "tinycolor2": "^1.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1664,6 +2607,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1696,6 +2674,24 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuidv7": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.0.2.tgz", @@ -1705,6 +2701,15 @@ "uuidv7": "cli.js" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -1736,6 +2741,21 @@ "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", @@ -1784,6 +2804,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -1831,6 +2857,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index 97a9abe8..d73ec525 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,14 @@ }, "dependencies": { "@inkjs/ui": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.19.1", "@runloop/api-client": "^0.55.0", "@runloop/rl-cli": "^0.0.1", + "@types/express": "^5.0.3", "chalk": "^5.3.0", "commander": "^12.1.0", "conf": "^13.0.1", + "express": "^5.1.0", "figures": "^6.1.0", "gradient-string": "^2.0.2", "ink": "^5.0.1", diff --git a/src/cli.ts b/src/cli.ts index fff2ca04..712e0224 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -150,11 +150,54 @@ blueprint } }); +// MCP server commands +const mcp = program + .command('mcp') + .description('Model Context Protocol (MCP) server commands'); + +mcp + .command('start') + .description('Start the MCP server') + .option('--http', 'Use HTTP/SSE transport instead of stdio') + .option('-p, --port ', 'Port to listen on for HTTP mode (default: 3000)', parseInt) + .action(async (options) => { + if (options.http) { + const { startMcpHttpServer } = await import('./commands/mcp-http.js'); + await startMcpHttpServer(options.port); + } else { + const { startMcpServer } = await import('./commands/mcp.js'); + await startMcpServer(); + } + }); + +mcp + .command('install') + .description('Install Runloop MCP server configuration in Claude Desktop') + .action(async () => { + const { installMcpConfig } = await import('./commands/mcp-install.js'); + await installMcpConfig(); + }); + +// Hidden command: 'rln mcp' without subcommand starts the server (for Claude Desktop config compatibility) +program + .command('mcp-server', { hidden: true }) + .option('--http', 'Use HTTP/SSE transport instead of stdio') + .option('-p, --port ', 'Port to listen on for HTTP mode (default: 3000)', parseInt) + .action(async (options) => { + if (options.http) { + const { startMcpHttpServer } = await import('./commands/mcp-http.js'); + await startMcpHttpServer(options.port); + } else { + const { startMcpServer } = await import('./commands/mcp.js'); + await startMcpServer(); + } + }); + // Main CLI entry point (async () => { - // Check if API key is configured (except for auth command) + // Check if API key is configured (except for auth and mcp commands) const args = process.argv.slice(2); - if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) { + if (args[0] !== 'auth' && args[0] !== 'mcp' && args[0] !== 'mcp-server' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) { const config = getConfig(); if (!config.apiKey) { console.error('\n❌ API key not configured. Run: rln auth\n'); diff --git a/src/commands/mcp-http.ts b/src/commands/mcp-http.ts new file mode 100644 index 00000000..4d422f0e --- /dev/null +++ b/src/commands/mcp-http.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export async function startMcpHttpServer(port?: number) { + // Get the path to the compiled MCP HTTP server + const serverPath = join(__dirname, '../mcp/server-http.js'); + + const env = { ...process.env }; + if (port) { + env.PORT = port.toString(); + } + + console.log(`Starting Runloop MCP HTTP server on port ${port || 3000}...`); + console.log('Press Ctrl+C to stop\n'); + + // Start the MCP HTTP server as a child process + const serverProcess = spawn('node', [serverPath], { + stdio: 'inherit', + env, + }); + + serverProcess.on('error', (error) => { + console.error('Failed to start MCP HTTP server:', error); + process.exit(1); + }); + + serverProcess.on('exit', (code) => { + if (code !== 0) { + console.error(`MCP HTTP server exited with code ${code}`); + process.exit(code || 1); + } + }); + + // Handle Ctrl+C + process.on('SIGINT', () => { + console.log('\nShutting down MCP HTTP server...'); + serverProcess.kill('SIGINT'); + process.exit(0); + }); +} diff --git a/src/commands/mcp-install.ts b/src/commands/mcp-install.ts new file mode 100644 index 00000000..4a9e054f --- /dev/null +++ b/src/commands/mcp-install.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { homedir, platform } from 'os'; +import { join } from 'path'; +import { execSync } from 'child_process'; + +function getClaudeConfigPath(): string { + const plat = platform(); + + if (plat === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } else if (plat === 'win32') { + const appData = process.env.APPDATA; + if (!appData) { + throw new Error('APPDATA environment variable not found'); + } + return join(appData, 'Claude', 'claude_desktop_config.json'); + } else { + // Linux + return join(homedir(), '.config', 'Claude', 'claude_desktop_config.json'); + } +} + +function getRlnPath(): string { + try { + const cmd = platform() === 'win32' ? 'where rln' : 'which rln'; + const path = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n')[0]; + return path; + } catch (error) { + // If rln not found in PATH, just use 'rln' and hope it works + return 'rln'; + } +} + +export async function installMcpConfig() { + try { + const configPath = getClaudeConfigPath(); + const rlnPath = getRlnPath(); + + console.log(`\n📍 Claude Desktop config location: ${configPath}`); + console.log(`📍 rln command location: ${rlnPath}\n`); + + // Read or create config + let config: any = { mcpServers: {} }; + + if (existsSync(configPath)) { + console.log('✓ Found existing Claude Desktop config'); + const content = readFileSync(configPath, 'utf-8'); + try { + config = JSON.parse(content); + if (!config.mcpServers) { + config.mcpServers = {}; + } + } catch (error) { + console.error('❌ Error: Claude config file exists but is not valid JSON'); + console.error('Please fix the file manually or delete it to create a new one'); + process.exit(1); + } + } else { + console.log('✓ No existing config found, will create new one'); + // Create directory if it doesn't exist + const configDir = join(configPath, '..'); + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + console.log(`✓ Created directory: ${configDir}`); + } + } + + // Check if runloop is already configured + if (config.mcpServers.runloop) { + console.log('\n⚠️ Runloop MCP server is already configured in Claude Desktop'); + console.log('\nCurrent configuration:'); + console.log(JSON.stringify(config.mcpServers.runloop, null, 2)); + + // Ask if they want to overwrite + console.log('\n❓ Do you want to overwrite it? (y/N): '); + + // For non-interactive mode, just exit + if (process.stdin.isTTY) { + const response = await new Promise((resolve) => { + process.stdin.once('data', (data) => { + resolve(data.toString().trim().toLowerCase()); + }); + }); + + if (response !== 'y' && response !== 'yes') { + console.log('\n✓ Keeping existing configuration'); + process.exit(0); + } + } else { + console.log('\n✓ Keeping existing configuration (non-interactive mode)'); + process.exit(0); + } + } + + // Add runloop MCP server config + config.mcpServers.runloop = { + command: rlnPath, + args: ['mcp', 'start'], + }; + + // Write config back + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + + console.log('\n✅ Successfully installed Runloop MCP server configuration!'); + console.log('\nConfiguration added:'); + console.log(JSON.stringify({ mcpServers: { runloop: config.mcpServers.runloop } }, null, 2)); + + console.log('\n📝 Next steps:'); + console.log('1. Restart Claude Desktop completely (quit and reopen)'); + console.log('2. Ask Claude: "List my devboxes" or "What Runloop tools do you have?"'); + console.log('\n💡 Tip: Make sure you\'ve run "rln auth" to configure your API key first!'); + + } catch (error: any) { + console.error('\n❌ Error installing MCP configuration:', error.message); + console.error('\n💡 You can manually add this configuration to your Claude Desktop config:'); + console.error(`\nFile location: ${getClaudeConfigPath()}`); + console.error('\nConfiguration to add:'); + console.error(JSON.stringify({ + mcpServers: { + runloop: { + command: 'rln', + args: ['mcp', 'start'], + }, + }, + }, null, 2)); + process.exit(1); + } +} diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 00000000..f8413288 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export async function startMcpServer() { + // Get the path to the compiled MCP server + const serverPath = join(__dirname, '../mcp/server.js'); + + // Start the MCP server as a child process + // The server uses stdio transport, so it communicates via stdin/stdout + const serverProcess = spawn('node', [serverPath], { + stdio: 'inherit', // Pass through stdin/stdout/stderr + }); + + serverProcess.on('error', (error) => { + console.error('Failed to start MCP server:', error); + process.exit(1); + }); + + serverProcess.on('exit', (code) => { + if (code !== 0) { + console.error(`MCP server exited with code ${code}`); + process.exit(code || 1); + } + }); + + // Handle Ctrl+C + process.on('SIGINT', () => { + serverProcess.kill('SIGINT'); + process.exit(0); + }); +} diff --git a/src/mcp/server-http.ts b/src/mcp/server-http.ts new file mode 100644 index 00000000..f7b9361a --- /dev/null +++ b/src/mcp/server-http.ts @@ -0,0 +1,434 @@ +#!/usr/bin/env node +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { getClient } from '../utils/client.js'; +import express from 'express'; + +// Define available tools for the MCP server +const TOOLS: Tool[] = [ + { + name: 'list_devboxes', + description: 'List all devboxes with optional filtering by status', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + description: 'Filter by status (running, provisioning, suspended, etc.)', + enum: ['running', 'provisioning', 'initializing', 'suspended', 'shutdown', 'failure'], + }, + limit: { + type: 'number', + description: 'Maximum number of devboxes to return', + }, + }, + }, + }, + { + name: 'get_devbox', + description: 'Get detailed information about a specific devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID', + }, + }, + required: ['id'], + }, + }, + { + name: 'create_devbox', + description: 'Create a new devbox with specified configuration', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name for the devbox', + }, + blueprint_id: { + type: 'string', + description: 'Blueprint ID to use as template', + }, + snapshot_id: { + type: 'string', + description: 'Snapshot ID to restore from', + }, + entrypoint: { + type: 'string', + description: 'Entrypoint script to run on startup', + }, + environment_variables: { + type: 'object', + description: 'Environment variables as key-value pairs', + }, + resource_size: { + type: 'string', + description: 'Resource size (SMALL, MEDIUM, LARGE, XLARGE)', + enum: ['SMALL', 'MEDIUM', 'LARGE', 'XLARGE'], + }, + keep_alive_seconds: { + type: 'number', + description: 'Keep alive time in seconds', + }, + }, + }, + }, + { + name: 'execute_command', + description: 'Execute a command on a devbox and get the result', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'The devbox ID to execute the command on', + }, + command: { + type: 'string', + description: 'The command to execute', + }, + }, + required: ['devbox_id', 'command'], + }, + }, + { + name: 'shutdown_devbox', + description: 'Shutdown a devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to shutdown', + }, + }, + required: ['id'], + }, + }, + { + name: 'suspend_devbox', + description: 'Suspend a devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to suspend', + }, + }, + required: ['id'], + }, + }, + { + name: 'resume_devbox', + description: 'Resume a suspended devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to resume', + }, + }, + required: ['id'], + }, + }, + { + name: 'list_blueprints', + description: 'List all available blueprints', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of blueprints to return', + }, + }, + }, + }, + { + name: 'list_snapshots', + description: 'List all snapshots', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'Filter snapshots by devbox ID', + }, + limit: { + type: 'number', + description: 'Maximum number of snapshots to return', + }, + }, + }, + }, + { + name: 'create_snapshot', + description: 'Create a snapshot of a devbox', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'The devbox ID to snapshot', + }, + name: { + type: 'string', + description: 'Name for the snapshot', + }, + }, + required: ['devbox_id'], + }, + }, +]; + +// Create the MCP server +const server = new Server( + { + name: 'runloop-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Handle tool listing +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: TOOLS, + }; +}); + +// Handle tool execution +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + const client = getClient(); + + if (!args) { + throw new Error('Missing arguments'); + } + + switch (name) { + case 'list_devboxes': { + const result = await client.devboxes.list({ + status: args.status as any, + limit: args.limit as number, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'get_devbox': { + const result = await client.devboxes.retrieve(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'create_devbox': { + const createParams: any = {}; + if (args.name) createParams.name = args.name; + if (args.blueprint_id) createParams.blueprint_id = args.blueprint_id; + if (args.snapshot_id) createParams.snapshot_id = args.snapshot_id; + if (args.entrypoint) createParams.entrypoint = args.entrypoint; + if (args.environment_variables) createParams.environment_variables = args.environment_variables; + if (args.resource_size) { + createParams.launch_parameters = { + resource_size_request: args.resource_size, + }; + } + if (args.keep_alive_seconds) { + if (!createParams.launch_parameters) createParams.launch_parameters = {}; + createParams.launch_parameters.keep_alive_time_seconds = args.keep_alive_seconds; + } + + const result = await client.devboxes.create(createParams); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'execute_command': { + const result = await client.devboxes.executeSync(args.devbox_id as string, { + command: args.command as string, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'shutdown_devbox': { + const result = await client.devboxes.shutdown(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'suspend_devbox': { + const result = await client.devboxes.suspend(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'resume_devbox': { + const result = await client.devboxes.resume(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_blueprints': { + const result = await client.blueprints.list({ + limit: args.limit as number, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_snapshots': { + const params: any = {}; + if (args.devbox_id) params.devbox_id = args.devbox_id; + + const allSnapshots: any[] = []; + let count = 0; + const limit = (args.limit as number) || 100; + + for await (const snapshot of client.devboxes.listDiskSnapshots(params)) { + allSnapshots.push(snapshot); + count++; + if (count >= limit) break; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(allSnapshots, null, 2), + }, + ], + }; + } + + case 'create_snapshot': { + const params: any = {}; + if (args.name) params.name = args.name; + + const result = await client.devboxes.snapshotDisk(args.devbox_id as string, params); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } +}); + +// Start the HTTP/SSE server +async function main() { + const app = express(); + const port = parseInt(process.env.PORT || '3000'); + + // Handle SSE endpoint + app.get('/sse', async (req, res) => { + console.log('New SSE connection established'); + + const transport = new SSEServerTransport('/message', res); + await server.connect(transport); + + // Keep connection alive + req.on('close', () => { + console.log('SSE connection closed'); + }); + }); + + // Handle message endpoint for client requests + app.post('/message', express.json(), async (req, res) => { + // The SSE transport handles this automatically + res.status(200).end(); + }); + + app.listen(port, () => { + console.log(`Runloop MCP HTTP server running on http://localhost:${port}`); + console.log(`SSE endpoint: http://localhost:${port}/sse`); + console.log(`Message endpoint: http://localhost:${port}/message`); + }); +} + +main().catch((error) => { + console.error('Fatal error in main():', error); + process.exit(1); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 00000000..a29f1af5 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,411 @@ +#!/usr/bin/env node +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { getClient } from '../utils/client.js'; + +// Define available tools for the MCP server +const TOOLS: Tool[] = [ + { + name: 'list_devboxes', + description: 'List all devboxes with optional filtering by status', + inputSchema: { + type: 'object', + properties: { + status: { + type: 'string', + description: 'Filter by status (running, provisioning, suspended, etc.)', + enum: ['running', 'provisioning', 'initializing', 'suspended', 'shutdown', 'failure'], + }, + limit: { + type: 'number', + description: 'Maximum number of devboxes to return', + }, + }, + }, + }, + { + name: 'get_devbox', + description: 'Get detailed information about a specific devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID', + }, + }, + required: ['id'], + }, + }, + { + name: 'create_devbox', + description: 'Create a new devbox with specified configuration', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name for the devbox', + }, + blueprint_id: { + type: 'string', + description: 'Blueprint ID to use as template', + }, + snapshot_id: { + type: 'string', + description: 'Snapshot ID to restore from', + }, + entrypoint: { + type: 'string', + description: 'Entrypoint script to run on startup', + }, + environment_variables: { + type: 'object', + description: 'Environment variables as key-value pairs', + }, + resource_size: { + type: 'string', + description: 'Resource size (SMALL, MEDIUM, LARGE, XLARGE)', + enum: ['SMALL', 'MEDIUM', 'LARGE', 'XLARGE'], + }, + keep_alive_seconds: { + type: 'number', + description: 'Keep alive time in seconds', + }, + }, + }, + }, + { + name: 'execute_command', + description: 'Execute a command on a devbox and get the result', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'The devbox ID to execute the command on', + }, + command: { + type: 'string', + description: 'The command to execute', + }, + }, + required: ['devbox_id', 'command'], + }, + }, + { + name: 'shutdown_devbox', + description: 'Shutdown a devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to shutdown', + }, + }, + required: ['id'], + }, + }, + { + name: 'suspend_devbox', + description: 'Suspend a devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to suspend', + }, + }, + required: ['id'], + }, + }, + { + name: 'resume_devbox', + description: 'Resume a suspended devbox by ID', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The devbox ID to resume', + }, + }, + required: ['id'], + }, + }, + { + name: 'list_blueprints', + description: 'List all available blueprints', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of blueprints to return', + }, + }, + }, + }, + { + name: 'list_snapshots', + description: 'List all snapshots', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'Filter snapshots by devbox ID', + }, + limit: { + type: 'number', + description: 'Maximum number of snapshots to return', + }, + }, + }, + }, + { + name: 'create_snapshot', + description: 'Create a snapshot of a devbox', + inputSchema: { + type: 'object', + properties: { + devbox_id: { + type: 'string', + description: 'The devbox ID to snapshot', + }, + name: { + type: 'string', + description: 'Name for the snapshot', + }, + }, + required: ['devbox_id'], + }, + }, +]; + +// Create the MCP server +const server = new Server( + { + name: 'runloop-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Handle tool listing +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: TOOLS, + }; +}); + +// Handle tool execution +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + const client = getClient(); + + if (!args) { + throw new Error('Missing arguments'); + } + + switch (name) { + case 'list_devboxes': { + const result = await client.devboxes.list({ + status: args.status as any, + limit: args.limit as number, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'get_devbox': { + const result = await client.devboxes.retrieve(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'create_devbox': { + const createParams: any = {}; + if (args.name) createParams.name = args.name; + if (args.blueprint_id) createParams.blueprint_id = args.blueprint_id; + if (args.snapshot_id) createParams.snapshot_id = args.snapshot_id; + if (args.entrypoint) createParams.entrypoint = args.entrypoint; + if (args.environment_variables) createParams.environment_variables = args.environment_variables; + if (args.resource_size) { + createParams.launch_parameters = { + resource_size_request: args.resource_size, + }; + } + if (args.keep_alive_seconds) { + if (!createParams.launch_parameters) createParams.launch_parameters = {}; + createParams.launch_parameters.keep_alive_time_seconds = args.keep_alive_seconds; + } + + const result = await client.devboxes.create(createParams); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'execute_command': { + const result = await client.devboxes.executeSync(args.devbox_id as string, { + command: args.command as string, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'shutdown_devbox': { + const result = await client.devboxes.shutdown(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'suspend_devbox': { + const result = await client.devboxes.suspend(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'resume_devbox': { + const result = await client.devboxes.resume(args.id as string); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_blueprints': { + const result = await client.blueprints.list({ + limit: args.limit as number, + }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case 'list_snapshots': { + const params: any = {}; + if (args.devbox_id) params.devbox_id = args.devbox_id; + + const allSnapshots: any[] = []; + let count = 0; + const limit = (args.limit as number) || 100; + + for await (const snapshot of client.devboxes.listDiskSnapshots(params)) { + allSnapshots.push(snapshot); + count++; + if (count >= limit) break; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(allSnapshots, null, 2), + }, + ], + }; + } + + case 'create_snapshot': { + const params: any = {}; + if (args.name) params.name = args.name; + + const result = await client.devboxes.snapshotDisk(args.devbox_id as string, params); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } +}); + +// Start the server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + + // Log to stderr so it doesn't interfere with MCP protocol on stdout + console.error('Runloop MCP server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error in main():', error); + process.exit(1); +}); From 3b4ead9765461bda8df40b4a39e4a90f6041cbcb Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 14:29:52 -0700 Subject: [PATCH 14/25] cp dines --- src/components/DevboxDetailPage.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 17c49cae..4312494b 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -404,10 +404,9 @@ export const DevboxDetailPage: React.FC = ({ devbox: init { label: 'Devboxes' }, { label: selectedDevbox.name || selectedDevbox.id, active: true } ]} /> -
- {/* Compact info section */} - + {/* Main info section */} + {selectedDevbox.name || selectedDevbox.id} @@ -428,11 +427,11 @@ export const DevboxDetailPage: React.FC = ({ devbox: init )} - {/* Compact resources + capabilities + source in one row */} - + {/* Resources + capabilities + source in one row */} + {/* Resources */} {(lp?.resource_size_request || lp?.custom_cpu_cores || lp?.custom_gb_memory || lp?.custom_disk_size || lp?.architecture) && ( - + {figures.squareSmallFilled} Resources {lp?.resource_size_request && `${lp.resource_size_request}`} @@ -446,7 +445,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Capabilities */} {hasCapabilities && ( - + {figures.tick} Capabilities {selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').join(', ')} @@ -454,7 +453,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Source */} {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( - + {figures.circleFilled} Source {selectedDevbox.blueprint_id && `BP: ${selectedDevbox.blueprint_id}`} @@ -464,16 +463,16 @@ export const DevboxDetailPage: React.FC = ({ devbox: init )} - {/* Metadata - compact */} + {/* Metadata */} {selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0 && ( - + )} - {/* Failure - compact */} + {/* Failure */} {selectedDevbox.failure_reason && ( - + {figures.cross} {selectedDevbox.failure_reason} From 9b7a939835d6839373115beac5f4e9dee0885029 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 16:50:34 -0700 Subject: [PATCH 15/25] cp dines --- src/commands/blueprint/list.tsx | 355 ++++++++-------------- src/commands/devbox/list.tsx | 23 +- src/commands/snapshot/list.tsx | 254 +++++----------- src/components/Breadcrumb.tsx | 4 +- src/components/ResourceListView.tsx | 455 ++++++++++++++++++++++++++++ 5 files changed, 648 insertions(+), 443 deletions(-) create mode 100644 src/components/ResourceListView.tsx diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index a6dc1ffb..f707f8f9 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -12,6 +12,7 @@ import { Breadcrumb } from '../../components/Breadcrumb.js'; import { MetadataDisplay } from '../../components/MetadataDisplay.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; import { OperationsMenu, Operation } from '../../components/OperationsMenu.js'; +import { ResourceListView, formatTimeAgo } from '../../components/ResourceListView.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; import { getBlueprintUrl } from '../../utils/url.js'; import { colors } from '../../utils/theme.js'; @@ -21,45 +22,20 @@ const MAX_FETCH = 100; type OperationType = 'create_devbox' | 'delete' | null; -// Format time ago -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - const ListBlueprintsUI: React.FC<{ onBack?: () => void; onExit?: () => void; }> = ({ onBack, onExit }) => { const { stdout } = useStdout(); const { exit: inkExit } = useApp(); - const [loading, setLoading] = React.useState(true); - const [blueprints, setBlueprints] = React.useState([]); - const [error, setError] = React.useState(null); - const [currentPage, setCurrentPage] = React.useState(0); - const [selectedIndex, setSelectedIndex] = React.useState(0); const [showDetails, setShowDetails] = React.useState(false); + const [selectedBlueprint, setSelectedBlueprint] = React.useState(null); const [selectedOperation, setSelectedOperation] = React.useState(0); const [executingOperation, setExecutingOperation] = React.useState(null); const [operationInput, setOperationInput] = React.useState(''); const [operationResult, setOperationResult] = React.useState(null); const [operationError, setOperationError] = React.useState(null); + const [loading, setLoading] = React.useState(false); // Calculate responsive column widths const terminalWidth = stdout?.columns || 120; @@ -89,31 +65,6 @@ const ListBlueprintsUI: React.FC<{ }, ]; - React.useEffect(() => { - const list = async () => { - try { - const client = getClient(); - const allBlueprints: any[] = []; - - let count = 0; - for await (const blueprint of client.blueprints.list()) { - allBlueprints.push(blueprint); - count++; - if (count >= MAX_FETCH) { - break; - } - } - - setBlueprints(allBlueprints); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }; - - list(); - }, []); // Clear console when transitioning to detail view const prevShowDetailsRef = React.useRef(showDetails); @@ -129,12 +80,14 @@ const ListBlueprintsUI: React.FC<{ if (executingOperation === 'delete' && !loading && selectedBlueprint) { executeOperation(); } - }, [executingOperation]); + }, [executingOperation, loading, selectedBlueprint]); const executeOperation = async () => { const client = getClient(); const blueprint = selectedBlueprint; + if (!blueprint) return; + try { setLoading(true); switch (executingOperation) { @@ -165,8 +118,6 @@ const ListBlueprintsUI: React.FC<{ }; useInput((input, key) => { - const pageBlueprints = currentBlueprints.length; - // Handle operation input mode if (executingOperation && !operationResult && !operationError) { const currentOp = allOperations.find((op) => op.key === executingOperation); @@ -230,76 +181,22 @@ const ListBlueprintsUI: React.FC<{ } return; } - - // Handle list view - if (key.upArrow && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < pageBlueprints - 1) { - setSelectedIndex(selectedIndex + 1); - } else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) { - setCurrentPage(currentPage + 1); - setSelectedIndex(0); - } else if ((input === 'p' || key.leftArrow) && currentPage > 0) { - setCurrentPage(currentPage - 1); - setSelectedIndex(0); - } else if (key.return) { - console.clear(); - setShowDetails(true); - } else if (input === 'o' && selectedBlueprint) { - // Open in browser - const url = getBlueprintUrl(selectedBlueprint.id); - const openBrowser = async () => { - const { exec } = await import('child_process'); - const platform = process.platform; - - let openCommand: string; - if (platform === 'darwin') { - openCommand = `open "${url}"`; - } else if (platform === 'win32') { - openCommand = `start "${url}"`; - } else { - openCommand = `xdg-open "${url}"`; - } - - exec(openCommand); - }; - openBrowser(); - } else if (key.escape) { - if (onBack) { - onBack(); - } else if (onExit) { - onExit(); - } else { - inkExit(); - } - } }); - const totalPages = Math.ceil(blueprints.length / PAGE_SIZE); - const startIndex = currentPage * PAGE_SIZE; - const endIndex = Math.min(startIndex + PAGE_SIZE, blueprints.length); - const currentBlueprints = blueprints.slice(startIndex, endIndex); - const selectedBlueprint = currentBlueprints[selectedIndex]; - - const buildComplete = blueprints.filter((b) => b.status === 'build_complete').length; - const building = blueprints.filter((b) => ['provisioning', 'building'].includes(b.status)).length; - const failed = blueprints.filter((b) => b.status === 'build_failed').length; - // Filter operations based on blueprint status - const operations = - selectedBlueprint - ? allOperations.filter((op) => { - const status = selectedBlueprint.status; - - // Only allow creating devbox if build is complete - if (op.key === 'create_devbox') { - return status === 'build_complete'; - } + const operations = selectedBlueprint + ? allOperations.filter((op) => { + const status = selectedBlueprint.status; + + // Only allow creating devbox if build is complete + if (op.key === 'create_devbox') { + return status === 'build_complete'; + } - // Allow delete for any status - return true; - }) - : allOperations; + // Allow delete for any status + return true; + }) + : allOperations; // Operation result display if (operationResult || operationError) { @@ -541,120 +438,112 @@ const ListBlueprintsUI: React.FC<{ ); } - // List view + // List view using ResourceListView return ( - <> - - {loading && } - {!loading && !error && blueprints.length === 0 && ( - - {figures.info} - No blueprints found. Try: - - rln blueprint create - - - )} - {!loading && !error && blueprints.length > 0 && ( - <> - - - {figures.tick} {buildComplete} - - - - {figures.ellipsis} {building} - - - - {figures.cross} {failed} - - - - {figures.hamburger} {blueprints.length} - {blueprints.length >= MAX_FETCH && '+'} - - {totalPages > 1 && ( - <> - - - Page {currentPage + 1}/{totalPages} - - - )} - - -
blueprint.id} - selectedIndex={selectedIndex} - columns={[ - createComponentColumn( - 'status', - 'Status', - (blueprint: any) => , - { width: 2 } - ), - createTextColumn( - 'id', - 'ID', - (blueprint: any) => (showFullId ? blueprint.id : blueprint.id.slice(0, 13)), - { - width: showFullId ? idWidth : 15, - color: colors.textDim, - dimColor: true, - bold: false, - } - ), - createTextColumn( - 'name', - 'Name', - (blueprint: any) => blueprint.name || '(unnamed)', - { width: nameWidth } - ), - createTextColumn( - 'description', - 'Description', - (blueprint: any) => blueprint.dockerfile_setup?.description || '', - { - width: descriptionWidth, - color: colors.textDim, - dimColor: true, - bold: false, - visible: showDescription, + { + const client = getClient(); + const allBlueprints: any[] = []; + let count = 0; + for await (const blueprint of client.blueprints.list()) { + allBlueprints.push(blueprint); + count++; + if (count >= MAX_FETCH) break; + } + return allBlueprints; + }, + columns: [ + createComponentColumn( + 'status', + 'Status', + (blueprint: any) => , + { width: 2 } + ), + createTextColumn( + 'id', + 'ID', + (blueprint: any) => (showFullId ? blueprint.id : blueprint.id.slice(0, 13)), + { + width: showFullId ? idWidth : 15, + color: colors.textDim, + dimColor: true, + bold: false, + } + ), + createTextColumn( + 'name', + 'Name', + (blueprint: any) => blueprint.name || '(unnamed)', + { width: nameWidth } + ), + createTextColumn( + 'description', + 'Description', + (blueprint: any) => blueprint.dockerfile_setup?.description || '', + { + width: descriptionWidth, + color: colors.textDim, + dimColor: true, + bold: false, + visible: showDescription, + } + ), + createTextColumn( + 'created', + 'Created', + (blueprint: any) => + blueprint.create_time_ms ? formatTimeAgo(blueprint.create_time_ms) : '', + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } + ), + ], + keyExtractor: (blueprint: any) => blueprint.id, + getStatus: (blueprint: any) => blueprint.status, + statusConfig: { + success: ['build_complete'], + warning: ['provisioning', 'building'], + error: ['build_failed'], + }, + emptyState: { + message: 'No blueprints found. Try:', + command: 'rln blueprint create', + }, + pageSize: PAGE_SIZE, + maxFetch: MAX_FETCH, + onSelect: (blueprint: any) => { + setSelectedBlueprint(blueprint); + setShowDetails(true); + }, + onBack: onBack, + onExit: onExit, + additionalShortcuts: [ + { + key: 'o', + label: 'Browser', + handler: (blueprint: any) => { + const url = getBlueprintUrl(blueprint.id); + const openBrowser = async () => { + const { exec } = await import('child_process'); + const platform = process.platform; + let openCommand: string; + if (platform === 'darwin') { + openCommand = `open "${url}"`; + } else if (platform === 'win32') { + openCommand = `start "${url}"`; + } else { + openCommand = `xdg-open "${url}"`; } - ), - createTextColumn( - 'created', - 'Created', - (blueprint: any) => - blueprint.create_time_ms ? formatTimeAgo(blueprint.create_time_ms) : '', - { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } - ), - ]} - /> - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Operations • [o] Open in Browser • - - {totalPages > 1 && ( - - {' '} - {figures.arrowLeft} - {figures.arrowRight} Page • - - )} - - {' '} - [Esc] Back - - - - )} - {error && } - + exec(openCommand); + }; + openBrowser(); + }, + }, + ], + breadcrumbItems: [{ label: 'Blueprints', active: true }], + }} + /> ); }; diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 75188e87..9b5a9f83 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -12,6 +12,7 @@ import { StatusBadge, getStatusDisplay } from '../../components/StatusBadge.js'; import { MetadataDisplay } from '../../components/MetadataDisplay.js'; import { Breadcrumb } from '../../components/Breadcrumb.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; +import { formatTimeAgo } from '../../components/ResourceListView.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; import { DevboxDetailPage } from '../../components/DevboxDetailPage.js'; import { DevboxCreatePage } from '../../components/DevboxCreatePage.js'; @@ -21,28 +22,6 @@ import { getDevboxUrl } from '../../utils/url.js'; import { runSSHSession, type SSHSessionConfig } from '../../utils/sshSession.js'; import { colors } from '../../utils/theme.js'; -// Format time ago in a succinct way -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - interface ListOptions { status?: string; output?: string; diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 880acf57..dd308aef 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -7,6 +7,7 @@ import { ErrorMessage } from '../../components/ErrorMessage.js'; import { StatusBadge } from '../../components/StatusBadge.js'; import { Breadcrumb } from '../../components/Breadcrumb.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; +import { ResourceListView, formatTimeAgo } from '../../components/ResourceListView.js'; import { createExecutor } from '../../utils/CommandExecutor.js'; import { colors } from '../../utils/theme.js'; @@ -18,40 +19,12 @@ interface ListOptions { const PAGE_SIZE = 10; const MAX_FETCH = 100; -// Format time ago -const formatTimeAgo = (timestamp: number): string => { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 60) return `${seconds}s ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - - const years = Math.floor(months / 12); - return `${years}y ago`; -}; - const ListSnapshotsUI: React.FC<{ devboxId?: string; onBack?: () => void; onExit?: () => void; }> = ({ devboxId, onBack, onExit }) => { const { stdout } = useStdout(); - const { exit: inkExit } = useApp(); - const [loading, setLoading] = React.useState(true); - const [snapshots, setSnapshots] = React.useState([]); - const [error, setError] = React.useState(null); - const [currentPage, setCurrentPage] = React.useState(0); - const [selectedIndex, setSelectedIndex] = React.useState(0); // Calculate responsive column widths const terminalWidth = stdout?.columns || 120; @@ -63,167 +36,76 @@ const ListSnapshotsUI: React.FC<{ const devboxWidth = 15; const timeWidth = 20; - React.useEffect(() => { - const list = async () => { - try { - const client = getClient(); - const allSnapshots: any[] = []; - - let count = 0; - const params = devboxId ? { devbox_id: devboxId } : {}; - for await (const snapshot of client.devboxes.listDiskSnapshots(params)) { - allSnapshots.push(snapshot); - count++; - if (count >= MAX_FETCH) { - break; - } - } - - setSnapshots(allSnapshots); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }; - - list(); - }, [devboxId]); - - useInput((input, key) => { - const pageSnapshots = currentSnapshots.length; - - if (key.upArrow && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < pageSnapshots - 1) { - setSelectedIndex(selectedIndex + 1); - } else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) { - setCurrentPage(currentPage + 1); - setSelectedIndex(0); - } else if ((input === 'p' || key.leftArrow) && currentPage > 0) { - setCurrentPage(currentPage - 1); - setSelectedIndex(0); - } else if (key.escape) { - if (onBack) { - onBack(); - } else if (onExit) { - onExit(); - } else { - inkExit(); - } - } - }); - - const totalPages = Math.ceil(snapshots.length / PAGE_SIZE); - const startIndex = currentPage * PAGE_SIZE; - const endIndex = Math.min(startIndex + PAGE_SIZE, snapshots.length); - const currentSnapshots = snapshots.slice(startIndex, endIndex); - - const ready = snapshots.filter((s) => s.status === 'ready').length; - const pending = snapshots.filter((s) => s.status !== 'ready').length; - return ( - <> - { + const client = getClient(); + const allSnapshots: any[] = []; + let count = 0; + const params = devboxId ? { devbox_id: devboxId } : {}; + for await (const snapshot of client.devboxes.listDiskSnapshots(params)) { + allSnapshots.push(snapshot); + count++; + if (count >= MAX_FETCH) break; + } + return allSnapshots; + }, + columns: [ + createComponentColumn( + 'status', + 'Status', + (snapshot: any) => , + { width: 2 } + ), + createTextColumn( + 'id', + 'ID', + (snapshot: any) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), + { width: showFullId ? idWidth : 15, color: colors.textDim, dimColor: true, bold: false } + ), + createTextColumn( + 'name', + 'Name', + (snapshot: any) => snapshot.name || '(unnamed)', + { width: nameWidth } + ), + createTextColumn( + 'devbox', + 'Devbox', + (snapshot: any) => snapshot.devbox_id || '', + { width: devboxWidth, color: colors.primary, dimColor: true, bold: false, visible: showDevboxId } + ), + createTextColumn( + 'created', + 'Created', + (snapshot: any) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', + { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } + ), + ], + keyExtractor: (snapshot: any) => snapshot.id, + getStatus: (snapshot: any) => snapshot.status, + statusConfig: { + success: ['ready'], + warning: ['creating', 'pending'], + error: ['failed'], + }, + emptyState: { + message: 'No snapshots found. Try:', + command: 'rln snapshot create ', + }, + pageSize: PAGE_SIZE, + maxFetch: MAX_FETCH, + onBack: onBack, + onExit: onExit, + breadcrumbItems: [ { label: 'Snapshots', active: !devboxId }, ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []), - ]} - /> - {loading && } - {!loading && !error && snapshots.length === 0 && ( - - {figures.info} - No snapshots found. Try: - - rln snapshot create <devbox-id> - - - )} - {!loading && !error && snapshots.length > 0 && ( - <> - - - {figures.tick} {ready} - - - - {figures.ellipsis} {pending} - - - - {figures.hamburger} {snapshots.length} - {snapshots.length >= MAX_FETCH && '+'} - - {totalPages > 1 && ( - <> - - - Page {currentPage + 1}/{totalPages} - - - )} - - -
snapshot.id} - selectedIndex={selectedIndex} - columns={[ - createComponentColumn( - 'status', - 'Status', - (snapshot: any) => , - { width: 2 } - ), - createTextColumn( - 'id', - 'ID', - (snapshot: any) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), - { width: showFullId ? idWidth : 15, color: colors.textDim, dimColor: true, bold: false } - ), - createTextColumn( - 'name', - 'Name', - (snapshot: any) => snapshot.name || '(unnamed)', - { width: nameWidth } - ), - createTextColumn( - 'devbox', - 'Devbox', - (snapshot: any) => snapshot.devbox_id || '', - { width: devboxWidth, color: colors.primary, dimColor: true, bold: false, visible: showDevboxId } - ), - createTextColumn( - 'created', - 'Created', - (snapshot: any) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', - { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } - ), - ]} - /> - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • - - {totalPages > 1 && ( - - {' '} - {figures.arrowLeft} - {figures.arrowRight} Page • - - )} - - {' '} - [Esc] Back - - - - )} - {error && } - + ], + }} + /> ); }; diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index f23d0f13..cd83b6e7 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { colors } from '../utils/theme.js'; -interface BreadcrumbItem { +export interface BreadcrumbItem { label: string; active?: boolean; } @@ -16,7 +16,7 @@ export const Breadcrumb: React.FC = React.memo(({ items }) => { const isDevEnvironment = env === 'dev'; return ( - + rl {isDevEnvironment && (dev)} diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx new file mode 100644 index 00000000..8ba4aa52 --- /dev/null +++ b/src/components/ResourceListView.tsx @@ -0,0 +1,455 @@ +import React from 'react'; +import { Box, Text, useInput, useStdout, useApp } from 'ink'; +import TextInput from 'ink-text-input'; +import figures from 'figures'; +import { Breadcrumb, BreadcrumbItem } from './Breadcrumb.js'; +import { SpinnerComponent } from './Spinner.js'; +import { ErrorMessage } from './ErrorMessage.js'; +import { Table, Column } from './Table.js'; +import { colors } from '../utils/theme.js'; + +// Format time ago in a succinct way +export const formatTimeAgo = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + + const years = Math.floor(months / 12); + return `${years}y ago`; +}; + +export interface ResourceListConfig { + /** Resource name (e.g., 'Devboxes', 'Blueprints', 'Snapshots') */ + resourceName: string; + + /** Plural resource name for display */ + resourceNamePlural: string; + + /** Function to fetch resources */ + fetchResources: () => Promise; + + /** Column definitions for the table */ + columns: Column[]; + + /** Key extractor for list items */ + keyExtractor: (item: T) => string; + + /** Get the status of a resource (for stats) */ + getStatus?: (item: T) => string; + + /** Status display configuration */ + statusConfig?: { + success: string[]; // e.g., ['running', 'build_complete', 'ready'] + warning: string[]; // e.g., ['provisioning', 'building'] + error: string[]; // e.g., ['failure', 'build_failed'] + }; + + /** Search configuration */ + searchConfig?: { + enabled: boolean; + fields: (item: T) => string[]; // Fields to search in + placeholder?: string; + }; + + /** Empty state configuration */ + emptyState?: { + message: string; + command?: string; + }; + + /** Pagination config */ + pageSize?: number; + maxFetch?: number; + + /** Callbacks */ + onSelect?: (item: T) => void; + onBack?: () => void; + onExit?: () => void; + + /** Additional keyboard shortcuts */ + additionalShortcuts?: { + key: string; + label: string; + handler: (item: T) => void; + }[]; + + /** Breadcrumb configuration */ + breadcrumbItems?: BreadcrumbItem[]; + + /** Auto-refresh configuration */ + autoRefresh?: { + enabled: boolean; + interval?: number; // milliseconds + }; +} + +interface ResourceListViewProps { + config: ResourceListConfig; +} + +export function ResourceListView({ config }: ResourceListViewProps) { + const { stdout } = useStdout(); + const { exit: inkExit } = useApp(); + const [loading, setLoading] = React.useState(true); + const [resources, setResources] = React.useState([]); + const [error, setError] = React.useState(null); + const [currentPage, setCurrentPage] = React.useState(0); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [searchMode, setSearchMode] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + const [refreshing, setRefreshing] = React.useState(false); + const [refreshIcon, setRefreshIcon] = React.useState(0); + + const pageSize = config.pageSize || 10; + const maxFetch = config.maxFetch || 100; + + // Calculate responsive dimensions + const terminalWidth = stdout?.columns || 120; + const terminalHeight = stdout?.rows || 30; + + // Fetch resources + const fetchData = React.useCallback(async (isInitialLoad: boolean = false) => { + try { + if (isInitialLoad) { + setRefreshing(true); + } + + const data = await config.fetchResources(); + setResources(data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + if (isInitialLoad) { + setTimeout(() => setRefreshing(false), 300); + } + } + }, [config.fetchResources]); + + // Initial load + React.useEffect(() => { + fetchData(true); + }, [fetchData]); + + // Auto-refresh + React.useEffect(() => { + if (config.autoRefresh?.enabled) { + const interval = setInterval(() => { + fetchData(false); + }, config.autoRefresh.interval || 3000); + return () => clearInterval(interval); + } + }, [config.autoRefresh, fetchData]); + + // Animate refresh icon + React.useEffect(() => { + const interval = setInterval(() => { + setRefreshIcon((prev) => (prev + 1) % 10); + }, 80); + return () => clearInterval(interval); + }, []); + + // Filter resources based on search query + const filteredResources = React.useMemo(() => { + if (!config.searchConfig?.enabled || !searchQuery.trim()) { + return resources; + } + + const query = searchQuery.toLowerCase(); + return resources.filter(resource => { + const fields = config.searchConfig!.fields(resource); + return fields.some(field => field.toLowerCase().includes(query)); + }); + }, [resources, searchQuery, config.searchConfig]); + + // Pagination + const totalPages = Math.ceil(filteredResources.length / pageSize); + const startIndex = currentPage * pageSize; + const endIndex = Math.min(startIndex + pageSize, filteredResources.length); + const currentResources = filteredResources.slice(startIndex, endIndex); + + // Ensure selected index is within bounds + React.useEffect(() => { + if (currentResources.length > 0 && selectedIndex >= currentResources.length) { + setSelectedIndex(Math.max(0, currentResources.length - 1)); + } + }, [currentResources.length, selectedIndex]); + + const selectedResource = currentResources[selectedIndex]; + + // Input handling + useInput((input, key) => { + // Handle Ctrl+C to force exit + if (key.ctrl && input === 'c') { + process.stdout.write('\x1b[?1049l'); // Exit alternate screen + process.exit(130); + } + + const pageResourcesCount = currentResources.length; + + // Skip input handling when in search mode + if (searchMode) { + if (key.escape) { + setSearchMode(false); + setSearchQuery(''); + } + return; + } + + // Handle list view navigation + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < pageResourcesCount - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) { + setCurrentPage(currentPage + 1); + setSelectedIndex(0); + } else if ((input === 'p' || key.leftArrow) && currentPage > 0) { + setCurrentPage(currentPage - 1); + setSelectedIndex(0); + } else if (key.return && selectedResource && config.onSelect) { + console.clear(); + config.onSelect(selectedResource); + } else if (input === '/' && config.searchConfig?.enabled) { + setSearchMode(true); + } else if (key.escape) { + if (searchQuery) { + setSearchQuery(''); + setCurrentPage(0); + setSelectedIndex(0); + } else { + if (config.onBack) { + config.onBack(); + } else if (config.onExit) { + config.onExit(); + } else { + inkExit(); + } + } + } else if (config.additionalShortcuts) { + // Handle additional shortcuts + const shortcut = config.additionalShortcuts.find(s => s.key === input); + if (shortcut && selectedResource) { + shortcut.handler(selectedResource); + } + } + }); + + // Calculate stats + const stats = React.useMemo(() => { + if (!config.statusConfig || !config.getStatus) { + return null; + } + + const successCount = resources.filter(r => + config.statusConfig!.success.includes(config.getStatus!(r)) + ).length; + + const warningCount = resources.filter(r => + config.statusConfig!.warning.includes(config.getStatus!(r)) + ).length; + + const errorCount = resources.filter(r => + config.statusConfig!.error.includes(config.getStatus!(r)) + ).length; + + return { successCount, warningCount, errorCount }; + }, [resources, config.statusConfig, config.getStatus]); + + // Loading state + if (loading) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Empty state + if (!loading && !error && resources.length === 0) { + return ( + <> + + {config.emptyState && ( + + {figures.info} + {config.emptyState.message} + {config.emptyState.command && ( + + {config.emptyState.command} + + )} + + )} + + ); + } + + // List view with data + return ( + <> + + + {/* Search bar */} + {config.searchConfig?.enabled && ( + <> + {searchMode && ( + + {figures.pointerSmall} Search: + { + setSearchMode(false); + setCurrentPage(0); + setSelectedIndex(0); + }} + /> + [Esc to cancel] + + )} + {!searchMode && searchQuery && ( + + {figures.info} Searching for: + {searchQuery} + ({currentResources.length} results) [/ to edit, Esc to clear] + + )} + + )} + + {/* Stats bar */} + {stats && ( + + + {figures.tick} {stats.successCount} + + + + {figures.ellipsis} {stats.warningCount} + + + + {figures.cross} {stats.errorCount} + + + + {figures.hamburger} {resources.length} + {resources.length >= maxFetch && '+'} + + {totalPages > 1 && ( + <> + + + Page {currentPage + 1}/{totalPages} + + + )} + + )} + + {/* Table */} +
+ + {/* Statistics Bar */} + + + {figures.hamburger} {resources.length} + + total + {totalPages > 1 && ( + <> + + + Page {currentPage + 1} of {totalPages} + + + )} + + + Showing {startIndex + 1}-{endIndex} of {filteredResources.length} + + + {refreshing ? ( + + {['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10]} + + ) : ( + + {figures.circleFilled} + + )} + + + {/* Help Bar */} + + + {figures.arrowUp} + {figures.arrowDown} Navigate + + {totalPages > 1 && ( + + {' '}• {figures.arrowLeft}{figures.arrowRight} Page + + )} + {config.onSelect && ( + + {' '}• [Enter] Details + + )} + {config.searchConfig?.enabled && ( + + {' '}• [/] Search + + )} + {config.additionalShortcuts && config.additionalShortcuts.map(shortcut => ( + + {' '}• [{shortcut.key}] {shortcut.label} + + ))} + + {' '}• [Esc] Back + + + + ); +} From f3fc190c889764c34e1edb7c08dc53038345c102 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 16:56:12 -0700 Subject: [PATCH 16/25] cp dines --- src/components/ActionsPopup.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index 962b51e5..e45f1f8e 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -38,7 +38,7 @@ export const ActionsPopup: React.FC = ({ // Render all lines with background const lines = [ - bgLine(chalk.hex(colors.primary).bold(` ${figures.play} Quick Actions`)), + bgLine(chalk.cyan.bold(` ${figures.play} Quick Actions`)), chalk.bgBlack(' '.repeat(contentWidth)), ...operations.map((op, index) => { const isSelected = index === selectedOperation; @@ -48,28 +48,28 @@ export const ActionsPopup: React.FC = ({ let styled: string; if (isSelected) { const colorFn = chalk[op.color as 'red' | 'green' | 'blue' | 'yellow' | 'magenta' | 'cyan']; - styled = typeof colorFn === 'function' ? colorFn.bold(content) : chalk.hex(colors.text).bold(content); + styled = typeof colorFn === 'function' ? colorFn.bold(content) : chalk.white.bold(content); } else { - styled = chalk.hex(colors.textDim)(content); + styled = chalk.gray(content); } return bgLine(styled); }), chalk.bgBlack(' '.repeat(contentWidth)), - bgLine(chalk.hex(colors.textDim).dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)), - bgLine(chalk.hex(colors.textDim).dim(` [Esc] Close`)), + bgLine(chalk.gray.dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)), + bgLine(chalk.gray.dim(` [Esc] Close`)), ]; // Draw custom border with background to fill gaps - const borderTop = chalk.hex(colors.primary)('╭' + '─'.repeat(contentWidth) + '╮'); - const borderBottom = chalk.hex(colors.primary)('╰' + '─'.repeat(contentWidth) + '╯'); + const borderTop = chalk.cyan('╭' + '─'.repeat(contentWidth) + '╮'); + const borderBottom = chalk.cyan('╰' + '─'.repeat(contentWidth) + '╯'); return ( {borderTop} {lines.map((line, i) => ( - {chalk.hex(colors.primary)('│')}{line}{chalk.hex(colors.primary)('│')} + {chalk.cyan('│')}{line}{chalk.cyan('│')} ))} {borderBottom} From a217cfd905237241e96232231ba8e6815e827bcb Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:03:38 -0700 Subject: [PATCH 17/25] cp dines --- src/commands/blueprint/list.tsx | 62 ++++++--- src/commands/devbox/list.tsx | 122 +++++++---------- src/commands/snapshot/list.tsx | 24 +--- src/components/ResourceListView.tsx | 30 ---- src/components/StatusBadge.tsx | 7 +- src/components/Table.tsx | 12 +- src/hooks/useCursorPagination.ts | 203 ++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 140 deletions(-) create mode 100644 src/hooks/useCursorPagination.ts diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index f707f8f9..52fb97e7 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -16,6 +16,7 @@ import { ResourceListView, formatTimeAgo } from '../../components/ResourceListVi import { createExecutor } from '../../utils/CommandExecutor.js'; import { getBlueprintUrl } from '../../utils/url.js'; import { colors } from '../../utils/theme.js'; +import { getStatusDisplay } from '../../components/StatusBadge.js'; const PAGE_SIZE = 10; const MAX_FETCH = 100; @@ -42,6 +43,8 @@ const ListBlueprintsUI: React.FC<{ const showDescription = terminalWidth >= 120; const showFullId = terminalWidth >= 80; + const statusIconWidth = 2; + const statusTextWidth = 10; const idWidth = 25; const nameWidth = terminalWidth >= 120 ? 30 : 25; const descriptionWidth = 40; @@ -456,23 +459,50 @@ const ListBlueprintsUI: React.FC<{ return allBlueprints; }, columns: [ - createComponentColumn( - 'status', - 'Status', - (blueprint: any) => , - { width: 2 } - ), - createTextColumn( - 'id', - 'ID', - (blueprint: any) => (showFullId ? blueprint.id : blueprint.id.slice(0, 13)), - { - width: showFullId ? idWidth : 15, - color: colors.textDim, - dimColor: true, - bold: false, + { + key: 'statusIcon', + label: '', + width: statusIconWidth, + render: (blueprint: any, index: number, isSelected: boolean) => { + const statusDisplay = getStatusDisplay(blueprint.status); + return ( + + {statusDisplay.icon}{' '} + + ); } - ), + }, + { + key: 'id', + label: 'ID', + width: idWidth + 1, + render: (blueprint: any, index: number, isSelected: boolean) => { + const value = blueprint.id; + const width = idWidth + 1; + const truncated = value.slice(0, width - 1); + const padded = truncated.padEnd(width, ' '); + return ( + + {padded} + + ); + } + }, + { + key: 'statusText', + label: 'Status', + width: statusTextWidth, + render: (blueprint: any, index: number, isSelected: boolean) => { + const statusDisplay = getStatusDisplay(blueprint.status); + const truncated = statusDisplay.text.slice(0, statusTextWidth); + const padded = truncated.padEnd(statusTextWidth, ' '); + return ( + + {padded} + + ); + } + }, createTextColumn( 'name', 'Name', diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 9b5a9f83..d03b7ace 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -38,7 +38,7 @@ const ListDevboxesUI: React.FC<{ }> = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => { const { exit: inkExit } = useApp(); const { stdout } = useStdout(); - const [loading, setLoading] = React.useState(true); + const [initialLoading, setInitialLoading] = React.useState(true); const [devboxes, setDevboxes] = React.useState([]); const [error, setError] = React.useState(null); const [currentPage, setCurrentPage] = React.useState(0); @@ -50,6 +50,7 @@ const ListDevboxesUI: React.FC<{ const [selectedOperation, setSelectedOperation] = React.useState(0); const [refreshing, setRefreshing] = React.useState(false); const [refreshIcon, setRefreshIcon] = React.useState(0); + const isNavigating = React.useRef(false); const [searchMode, setSearchMode] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [totalCount, setTotalCount] = React.useState(0); @@ -107,7 +108,7 @@ const ListDevboxesUI: React.FC<{ // Check if we need to focus on a specific devbox after returning from SSH React.useEffect(() => { - if (focusDevboxId && devboxes.length > 0 && !loading) { + if (focusDevboxId && devboxes.length > 0 && !initialLoading) { // Find the devbox in the current page const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId); if (devboxIndex !== -1) { @@ -115,20 +116,32 @@ const ListDevboxesUI: React.FC<{ setShowDetails(true); } } - }, [devboxes, loading, focusDevboxId]); + }, [devboxes, initialLoading, focusDevboxId]); + // Clear cache when search query changes React.useEffect(() => { - const list = async (isInitialLoad: boolean = false) => { + pageCache.current.clear(); + lastIdCache.current.clear(); + setCurrentPage(0); + }, [searchQuery]); + + React.useEffect(() => { + const list = async (isInitialLoad: boolean = false, isBackgroundRefresh: boolean = false) => { try { + // Set navigating flag at the start (but not for background refresh) + if (!isBackgroundRefresh) { + isNavigating.current = true; + } + // Only show refreshing indicator on initial load if (isInitialLoad) { setRefreshing(true); } // Check if we have cached data for this page - if (!isInitialLoad && pageCache.current.has(currentPage)) { + if (!isInitialLoad && !isBackgroundRefresh && pageCache.current.has(currentPage)) { setDevboxes(pageCache.current.get(currentPage) || []); - setLoading(false); + isNavigating.current = false; return; } @@ -148,6 +161,9 @@ const ListDevboxesUI: React.FC<{ if (status) { queryParams.status = status as 'provisioning' | 'initializing' | 'running' | 'suspending' | 'suspended' | 'resuming' | 'failure' | 'shutdown'; } + if (searchQuery) { + queryParams.search = searchQuery; + } // Fetch only the current page const page = await client.devboxes.list(queryParams); @@ -185,28 +201,31 @@ const ListDevboxesUI: React.FC<{ } catch (err) { setError(err as Error); } finally { - setLoading(false); - // Show refresh indicator briefly + if (!isBackgroundRefresh) { + isNavigating.current = false; + } + // Only set initialLoading to false after first successful load if (isInitialLoad) { + setInitialLoading(false); setTimeout(() => setRefreshing(false), 300); } } }; - list(true); + // Only treat as initial load on first mount + const isFirstMount = initialLoading; + list(isFirstMount, false); - // Poll every 3 seconds (increased from 2), but only when in list view + // Poll every 3 seconds (increased from 2), but only when in list view and not navigating const interval = setInterval(() => { - if (!showDetails && !showCreate && !showActions) { - // Clear cache on refresh to get latest data - pageCache.current.clear(); - lastIdCache.current.clear(); - list(false); + if (!showDetails && !showCreate && !showActions && !isNavigating.current) { + // Don't clear cache on background refresh - just update the current page + list(false, true); } }, 3000); return () => clearInterval(interval); - }, [showDetails, showCreate, showActions, currentPage]); + }, [showDetails, showCreate, showActions, currentPage, searchQuery]); // Animate refresh icon only when in list view React.useEffect(() => { @@ -286,10 +305,10 @@ const ListDevboxesUI: React.FC<{ setSelectedIndex(selectedIndex - 1); } else if (key.downArrow && selectedIndex < pageDevboxes - 1) { setSelectedIndex(selectedIndex + 1); - } else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) { + } else if ((input === 'n' || key.rightArrow) && !isNavigating.current && currentPage < totalPages - 1) { setCurrentPage(currentPage + 1); setSelectedIndex(0); - } else if ((input === 'p' || key.leftArrow) && currentPage > 0) { + } else if ((input === 'p' || key.leftArrow) && !isNavigating.current && currentPage > 0) { setCurrentPage(currentPage - 1); setSelectedIndex(0); } else if (key.return) { @@ -342,22 +361,8 @@ const ListDevboxesUI: React.FC<{ } }); - // Filter devboxes based on search query (client-side only for current page) - const filteredDevboxes = React.useMemo(() => { - if (!searchQuery.trim()) return devboxes; - - const query = searchQuery.toLowerCase(); - return devboxes.filter(devbox => { - return ( - devbox.id?.toLowerCase().includes(query) || - devbox.name?.toLowerCase().includes(query) || - devbox.status?.toLowerCase().includes(query) - ); - }); - }, [devboxes, searchQuery]); - - // Current page is already fetched, no need to slice - const currentDevboxes = filteredDevboxes; + // No client-side filtering - search is handled server-side + const currentDevboxes = devboxes; // Ensure selected index is within bounds after filtering React.useEffect(() => { @@ -451,7 +456,7 @@ const ListDevboxesUI: React.FC<{ - {!loading && !error && devboxes.length > 0 && ( + {!initialLoading && !error && devboxes.length > 0 && ( <>
{ const statusDisplay = getStatusDisplay(devbox.status); - // Truncate icon to fit width and pad - const icon = statusDisplay.icon.slice(0, statusIconWidth); - const padded = icon.padEnd(statusIconWidth, ' '); - return ( - - {padded} + + {statusDisplay.icon}{' '} ); } @@ -494,7 +495,7 @@ const ListDevboxesUI: React.FC<{ const padded = truncated.padEnd(statusTextWidth, ' '); return ( - + {padded} ); @@ -550,8 +551,8 @@ const ListDevboxesUI: React.FC<{ ); } - // If loading or error, show that first - if (loading) { + // If initial loading or error, show that first + if (initialLoading) { return ( <> - - - {figures.info} - No devboxes found. Try: - - rln devbox create - - - - ); - } - - // List view with data + // List view with data (always show, even if empty) return ( <> {figures.info} Searching for: {searchQuery} - ({currentDevboxes.length} results) [/ to edit, Esc to clear] + ({totalCount} results) [/ to edit, Esc to clear] )}
devbox.id} selectedIndex={selectedIndex} - title={`devboxes[${searchQuery ? currentDevboxes.length : totalCount}]`} + title={`devboxes[${totalCount}]`} columns={[ { key: 'statusIcon', @@ -635,13 +619,9 @@ const ListDevboxesUI: React.FC<{ render: (devbox: any, index: number, isSelected: boolean) => { const statusDisplay = getStatusDisplay(devbox.status); - // Truncate icon to fit width and pad - const icon = statusDisplay.icon.slice(0, statusIconWidth); - const padded = icon.padEnd(statusIconWidth, ' '); - return ( - - {padded} + + {statusDisplay.icon}{' '} ); } @@ -663,7 +643,7 @@ const ListDevboxesUI: React.FC<{ const padded = truncated.padEnd(statusTextWidth, ' '); return ( - + {padded} ); diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index dd308aef..95460936 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -4,7 +4,7 @@ import figures from 'figures'; import { getClient } from '../../utils/client.js'; import { SpinnerComponent } from '../../components/Spinner.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; -import { StatusBadge } from '../../components/StatusBadge.js'; +import { StatusBadge, getStatusDisplay } from '../../components/StatusBadge.js'; import { Breadcrumb } from '../../components/Breadcrumb.js'; import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js'; import { ResourceListView, formatTimeAgo } from '../../components/ResourceListView.js'; @@ -31,6 +31,8 @@ const ListSnapshotsUI: React.FC<{ const showDevboxId = terminalWidth >= 100 && !devboxId; // Hide devbox column if filtering by devbox const showFullId = terminalWidth >= 80; + const statusIconWidth = 2; + const statusTextWidth = 10; const idWidth = 25; const nameWidth = terminalWidth >= 120 ? 30 : 25; const devboxWidth = 15; @@ -54,17 +56,11 @@ const ListSnapshotsUI: React.FC<{ return allSnapshots; }, columns: [ - createComponentColumn( - 'status', - 'Status', - (snapshot: any) => , - { width: 2 } - ), createTextColumn( 'id', 'ID', - (snapshot: any) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), - { width: showFullId ? idWidth : 15, color: colors.textDim, dimColor: true, bold: false } + (snapshot: any) => snapshot.id, + { width: idWidth, color: colors.textDim, dimColor: true, bold: false } ), createTextColumn( 'name', @@ -75,23 +71,17 @@ const ListSnapshotsUI: React.FC<{ createTextColumn( 'devbox', 'Devbox', - (snapshot: any) => snapshot.devbox_id || '', + (snapshot: any) => snapshot.source_devbox_id || '', { width: devboxWidth, color: colors.primary, dimColor: true, bold: false, visible: showDevboxId } ), createTextColumn( 'created', 'Created', - (snapshot: any) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', + (snapshot: any) => snapshot.create_time_ms ? formatTimeAgo(snapshot.create_time_ms) : '', { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ], keyExtractor: (snapshot: any) => snapshot.id, - getStatus: (snapshot: any) => snapshot.status, - statusConfig: { - success: ['ready'], - warning: ['creating', 'pending'], - error: ['failed'], - }, emptyState: { message: 'No snapshots found. Try:', command: 'rln snapshot create ', diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx index 8ba4aa52..2746466f 100644 --- a/src/components/ResourceListView.tsx +++ b/src/components/ResourceListView.tsx @@ -350,36 +350,6 @@ export function ResourceListView({ config }: ResourceListViewProps) { )} - {/* Stats bar */} - {stats && ( - - - {figures.tick} {stats.successCount} - - - - {figures.ellipsis} {stats.warningCount} - - - - {figures.cross} {stats.errorCount} - - - - {figures.hamburger} {resources.length} - {resources.length >= maxFetch && '+'} - - {totalPages > 1 && ( - <> - - - Page {currentPage + 1}/{totalPages} - - - )} - - )} - {/* Table */}
{ if (!status) { - return { icon: figures.questionMarkPrefix, color: colors.textDim, text: 'UNKNOWN' }; + return { icon: figures.questionMarkPrefix, color: colors.textDim, text: 'UNKNOWN ' }; } switch (status) { @@ -37,13 +37,14 @@ export const getStatusDisplay = (status: string): StatusDisplay => { case 'suspending': return { icon: figures.ellipsis, color: colors.warning, text: 'SUSPENDING' }; case 'ready': - return { icon: figures.tick, color: colors.success, text: 'READY ' }; + return { icon: figures.bullet, color: colors.success, text: 'READY ' }; case 'build_complete': case 'building_complete': - return { icon: figures.tick, color: colors.success, text: 'COMPLETE ' }; + return { icon: figures.bullet, color: colors.success, text: 'COMPLETE ' }; case 'building': return { icon: figures.ellipsis, color: colors.warning, text: 'BUILDING ' }; case 'build_failed': + case 'failed': return { icon: figures.cross, color: colors.error, text: 'FAILED ' }; default: // Truncate and pad any unknown status to 10 chars to match column width diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 7e76430e..dfe0920b 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -139,12 +139,18 @@ export function createTextColumn( const bold = options?.bold !== undefined ? options.bold : isSelected; const dimColor = options?.dimColor || false; - // Pad the value to fill the full width - const truncated = value.slice(0, width); + // Truncate and add ellipsis if text is too long + let truncated: string; + if (value.length > width) { + // Reserve space for ellipsis if truncating + truncated = value.slice(0, width - 1) + '…'; + } else { + truncated = value; + } const padded = truncated.padEnd(width, ' '); return ( - + {padded} ); diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts new file mode 100644 index 00000000..5ab571d8 --- /dev/null +++ b/src/hooks/useCursorPagination.ts @@ -0,0 +1,203 @@ +import React from 'react'; + +export interface CursorPaginationConfig { + /** Page size (items per page) */ + pageSize: number; + + /** Fetch function that takes query params and returns a page of results */ + fetchPage: (params: { limit: number; starting_at?: string; [key: string]: any }) => Promise<{ + items: T[]; + total_count?: number; + has_more?: boolean; + }>; + + /** Additional query params (e.g., status filter) */ + queryParams?: Record; + + /** Auto-refresh interval in milliseconds (0 to disable) */ + refreshInterval?: number; + + /** Key extractor to get ID from item */ + getItemId: (item: T) => string; +} + +export interface CursorPaginationResult { + /** Current page items */ + items: T[]; + + /** Loading state */ + loading: boolean; + + /** Error state */ + error: Error | null; + + /** Current page number (0-indexed) */ + currentPage: number; + + /** Total count of items (if available from API) */ + totalCount: number; + + /** Whether there are more pages */ + hasMore: boolean; + + /** Whether currently refreshing */ + refreshing: boolean; + + /** Go to next page */ + nextPage: () => void; + + /** Go to previous page */ + prevPage: () => void; + + /** Go to specific page */ + goToPage: (page: number) => void; + + /** Refresh current page */ + refresh: () => void; + + /** Clear cache */ + clearCache: () => void; +} + +export function useCursorPagination( + config: CursorPaginationConfig +): CursorPaginationResult { + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [currentPage, setCurrentPage] = React.useState(0); + const [totalCount, setTotalCount] = React.useState(0); + const [hasMore, setHasMore] = React.useState(false); + const [refreshing, setRefreshing] = React.useState(false); + + // Cache for page data and cursors + const pageCache = React.useRef>(new Map()); + const lastIdCache = React.useRef>(new Map()); + + const fetchData = React.useCallback(async (isInitialLoad: boolean = false) => { + try { + if (isInitialLoad) { + setRefreshing(true); + } + setLoading(true); + + // Check cache first (skip on refresh) + if (!isInitialLoad && pageCache.current.has(currentPage)) { + setItems(pageCache.current.get(currentPage) || []); + setLoading(false); + return; + } + + const pageItems: T[] = []; + + // Get starting_at cursor from previous page's last ID + const startingAt = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined; + + // Build query params + const queryParams: any = { + limit: config.pageSize, + ...config.queryParams, + }; + if (startingAt) { + queryParams.starting_at = startingAt; + } + + // Fetch the page + const result = await config.fetchPage(queryParams); + + // Extract items (handle both array response and paginated response) + const fetchedItems = Array.isArray(result) ? result : result.items; + pageItems.push(...fetchedItems.slice(0, config.pageSize)); + + // Update pagination metadata + if (!Array.isArray(result)) { + setTotalCount(result.total_count || pageItems.length); + setHasMore(result.has_more || false); + } else { + setTotalCount(pageItems.length); + setHasMore(false); + } + + // Cache the page data and last ID + if (pageItems.length > 0) { + pageCache.current.set(currentPage, pageItems); + lastIdCache.current.set(currentPage, config.getItemId(pageItems[pageItems.length - 1])); + } + + // Update items for current page + setItems(pageItems); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + if (isInitialLoad) { + setTimeout(() => setRefreshing(false), 300); + } + } + }, [currentPage, config]); + + // Initial load and page changes + React.useEffect(() => { + fetchData(true); + }, [fetchData, currentPage]); + + // Auto-refresh + React.useEffect(() => { + if (!config.refreshInterval || config.refreshInterval <= 0) { + return; + } + + const interval = setInterval(() => { + // Clear cache on refresh + pageCache.current.clear(); + lastIdCache.current.clear(); + fetchData(false); + }, config.refreshInterval); + + return () => clearInterval(interval); + }, [config.refreshInterval, fetchData]); + + const nextPage = React.useCallback(() => { + if (!loading && hasMore) { + setCurrentPage(prev => prev + 1); + } + }, [loading, hasMore]); + + const prevPage = React.useCallback(() => { + if (!loading && currentPage > 0) { + setCurrentPage(prev => prev - 1); + } + }, [loading, currentPage]); + + const goToPage = React.useCallback((page: number) => { + if (!loading && page >= 0) { + setCurrentPage(page); + } + }, [loading]); + + const refresh = React.useCallback(() => { + pageCache.current.clear(); + lastIdCache.current.clear(); + fetchData(true); + }, [fetchData]); + + const clearCache = React.useCallback(() => { + pageCache.current.clear(); + lastIdCache.current.clear(); + }, []); + + return { + items, + loading, + error, + currentPage, + totalCount, + hasMore, + refreshing, + nextPage, + prevPage, + goToPage, + refresh, + clearCache, + }; +} From fa018f77f2c03481a01308f83a911153f004796a Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:03:46 -0700 Subject: [PATCH 18/25] 0.0.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c77243a..e64fc56a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.0.3", + "version": "0.0.4", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/package.json b/package.json index d73ec525..f8a81218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.0.3", + "version": "0.0.4", "description": "Beautiful CLI for Runloop devbox management", "type": "module", "bin": { From 258983950fe33e2f63e525b025b965d96ae6785a Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:04:10 -0700 Subject: [PATCH 19/25] 0.0.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e64fc56a..e426e932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.0.4", + "version": "0.0.5", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/package.json b/package.json index f8a81218..fdcb04d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.0.4", + "version": "0.0.5", "description": "Beautiful CLI for Runloop devbox management", "type": "module", "bin": { From 0eedc0207a439fbaaaa1e57724f740cc40caf5cc Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:05:30 -0700 Subject: [PATCH 20/25] cp dines --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fdcb04d2..f8a81218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.0.5", + "version": "0.0.4", "description": "Beautiful CLI for Runloop devbox management", "type": "module", "bin": { From d39fd60d219985cc56cb6048b4d02d3b43b0b07a Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:09:24 -0700 Subject: [PATCH 21/25] cp dines --- src/commands/blueprint/list.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 52fb97e7..19ef5622 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -542,10 +542,6 @@ const ListBlueprintsUI: React.FC<{ }, pageSize: PAGE_SIZE, maxFetch: MAX_FETCH, - onSelect: (blueprint: any) => { - setSelectedBlueprint(blueprint); - setShowDetails(true); - }, onBack: onBack, onExit: onExit, additionalShortcuts: [ From d3ac5e11c1e328739f165ed21c567964fc4b5c72 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Tue, 7 Oct 2025 18:11:11 -0700 Subject: [PATCH 22/25] cp dines --- .prettierignore | 7 + .prettierrc | 8 + package-lock.json | 21 +- package.json | 5 +- src/cli.ts | 43 +-- src/commands/blueprint/list.tsx | 67 ++-- src/commands/devbox/delete.tsx | 7 +- src/commands/devbox/exec.tsx | 10 +- src/commands/devbox/list.tsx | 366 ++++++++++++++------ src/commands/devbox/upload.tsx | 10 +- src/commands/mcp-http.ts | 4 +- src/commands/mcp-install.ts | 33 +- src/commands/mcp.ts | 4 +- src/commands/menu.tsx | 6 +- src/commands/snapshot/create.tsx | 5 +- src/commands/snapshot/delete.tsx | 7 +- src/commands/snapshot/list.tsx | 37 +- src/components/ActionsPopup.tsx | 6 +- src/components/Banner.tsx | 2 +- src/components/Breadcrumb.tsx | 27 +- src/components/DetailView.tsx | 36 +- src/components/DevboxActionsMenu.tsx | 462 +++++++++++++++++++------ src/components/DevboxCard.tsx | 7 +- src/components/DevboxCreatePage.tsx | 182 +++++++--- src/components/DevboxDetailPage.tsx | 487 +++++++++++++++++++++------ src/components/MainMenu.tsx | 55 +-- src/components/MetadataDisplay.tsx | 19 +- src/components/OperationsMenu.tsx | 3 +- src/components/ResourceListView.tsx | 132 +++++--- src/components/SuccessMessage.tsx | 5 +- src/components/Table.example.tsx | 74 ++-- src/components/Table.tsx | 69 ++-- src/hooks/useCursorPagination.ts | 134 ++++---- src/mcp/server-http.ts | 7 +- src/mcp/server.ts | 7 +- src/utils/CommandExecutor.ts | 12 +- src/utils/client.ts | 2 +- src/utils/output.ts | 13 +- src/utils/sshSession.ts | 32 +- 39 files changed, 1627 insertions(+), 786 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..26512ded --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +build +coverage +.next +*.log +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d5076387 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "avoid" +} diff --git a/package-lock.json b/package-lock.json index e426e932..63c50dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.0.5", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.0.5", + "version": "0.0.4", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", @@ -35,6 +35,7 @@ "devDependencies": { "@types/node": "^22.7.9", "@types/react": "^18.3.11", + "prettier": "^3.6.2", "typescript": "^5.6.3" }, "engines": { @@ -2019,6 +2020,22 @@ "node": ">=16.20.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index f8a81218..6f1a9650 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "version:patch": "npm version patch", "version:minor": "npm version minor", "version:major": "npm version major", - "release": "npm run build && npm publish" + "release": "npm run build && npm publish", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json}\"" }, "keywords": [ "runloop", @@ -68,6 +70,7 @@ "devDependencies": { "@types/node": "^22.7.9", "@types/react": "^18.3.11", + "prettier": "^3.6.2", "typescript": "^5.6.3" } } diff --git a/src/cli.ts b/src/cli.ts index 712e0224..abc73be1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,10 +27,7 @@ process.on('SIGINT', () => { const program = new Command(); -program - .name('rln') - .description('Beautiful CLI for Runloop devbox management') - .version(VERSION); +program.name('rln').description('Beautiful CLI for Runloop devbox management').version(VERSION); program .command('auth') @@ -41,10 +38,7 @@ program }); // Devbox commands -const devbox = program - .command('devbox') - .description('Manage devboxes') - .alias('d'); +const devbox = program.command('devbox').description('Manage devboxes').alias('d'); devbox .command('create') @@ -59,7 +53,7 @@ devbox .description('List all devboxes') .option('-s, --status ', 'Filter by status') .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') - .action(async (options) => { + .action(async options => { // Only use alternate screen for interactive mode if (!options.output) { const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); @@ -90,17 +84,14 @@ devbox .action(uploadFile); // Snapshot commands -const snapshot = program - .command('snapshot') - .description('Manage devbox snapshots') - .alias('snap'); +const snapshot = program.command('snapshot').description('Manage devbox snapshots').alias('snap'); snapshot .command('list') .description('List all snapshots') .option('-d, --devbox ', 'Filter by devbox ID') .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') - .action(async (options) => { + .action(async options => { const { listSnapshots } = await import('./commands/snapshot/list.js'); if (!options.output) { const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); @@ -131,16 +122,13 @@ snapshot }); // Blueprint commands -const blueprint = program - .command('blueprint') - .description('Manage blueprints') - .alias('bp'); +const blueprint = program.command('blueprint').description('Manage blueprints').alias('bp'); blueprint .command('list') .description('List all blueprints') .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)') - .action(async (options) => { + .action(async options => { const { listBlueprints } = await import('./commands/blueprint/list.js'); if (!options.output) { const { runInteractiveCommand } = await import('./utils/interactiveCommand.js'); @@ -151,16 +139,14 @@ blueprint }); // MCP server commands -const mcp = program - .command('mcp') - .description('Model Context Protocol (MCP) server commands'); +const mcp = program.command('mcp').description('Model Context Protocol (MCP) server commands'); mcp .command('start') .description('Start the MCP server') .option('--http', 'Use HTTP/SSE transport instead of stdio') .option('-p, --port ', 'Port to listen on for HTTP mode (default: 3000)', parseInt) - .action(async (options) => { + .action(async options => { if (options.http) { const { startMcpHttpServer } = await import('./commands/mcp-http.js'); await startMcpHttpServer(options.port); @@ -183,7 +169,7 @@ program .command('mcp-server', { hidden: true }) .option('--http', 'Use HTTP/SSE transport instead of stdio') .option('-p, --port ', 'Port to listen on for HTTP mode (default: 3000)', parseInt) - .action(async (options) => { + .action(async options => { if (options.http) { const { startMcpHttpServer } = await import('./commands/mcp-http.js'); await startMcpHttpServer(options.port); @@ -197,7 +183,14 @@ program (async () => { // Check if API key is configured (except for auth and mcp commands) const args = process.argv.slice(2); - if (args[0] !== 'auth' && args[0] !== 'mcp' && args[0] !== 'mcp-server' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) { + if ( + args[0] !== 'auth' && + args[0] !== 'mcp' && + args[0] !== 'mcp-server' && + args[0] !== '--help' && + args[0] !== '-h' && + args.length > 0 + ) { const config = getConfig(); if (!config.apiKey) { console.error('\n❌ API key not configured. Run: rln auth\n'); diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 19ef5622..3bfa7a91 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -68,7 +68,6 @@ const ListBlueprintsUI: React.FC<{ }, ]; - // Clear console when transitioning to detail view const prevShowDetailsRef = React.useRef(showDetails); React.useEffect(() => { @@ -123,7 +122,7 @@ const ListBlueprintsUI: React.FC<{ useInput((input, key) => { // Handle operation input mode if (executingOperation && !operationResult && !operationError) { - const currentOp = allOperations.find((op) => op.key === executingOperation); + const currentOp = allOperations.find(op => op.key === executingOperation); if (currentOp?.needsInput) { if (key.return) { executeOperation(); @@ -188,7 +187,7 @@ const ListBlueprintsUI: React.FC<{ // Filter operations based on blueprint status const operations = selectedBlueprint - ? allOperations.filter((op) => { + ? allOperations.filter(op => { const status = selectedBlueprint.status; // Only allow creating devbox if build is complete @@ -203,8 +202,7 @@ const ListBlueprintsUI: React.FC<{ // Operation result display if (operationResult || operationError) { - const operationLabel = - operations.find((o) => o.key === executingOperation)?.label || 'Operation'; + const operationLabel = operations.find(o => o.key === executingOperation)?.label || 'Operation'; return ( <> op.key === executingOperation); + const currentOp = allOperations.find(op => op.key === executingOperation); const needsInput = currentOp?.needsInput; const operationLabel = currentOp?.label || 'Operation'; @@ -377,21 +375,13 @@ const ListBlueprintsUI: React.FC<{ {figures.squareSmallFilled} Dockerfile Setup - {ds.base_image && ( - Base Image: {ds.base_image} - )} - {ds.entrypoint && ( - Entrypoint: {ds.entrypoint} - )} + {ds.base_image && Base Image: {ds.base_image}} + {ds.entrypoint && Entrypoint: {ds.entrypoint}} {ds.system_packages && ds.system_packages.length > 0 && ( - - System Packages: {ds.system_packages.join(', ')} - + System Packages: {ds.system_packages.join(', ')} )} {ds.python_packages && ds.python_packages.length > 0 && ( - - Python Packages: {ds.python_packages.join(', ')} - + Python Packages: {ds.python_packages.join(', ')} )} )} @@ -419,14 +409,14 @@ const ListBlueprintsUI: React.FC<{ { + onNavigate={direction => { if (direction === 'up' && selectedOperation > 0) { setSelectedOperation(selectedOperation - 1); } else if (direction === 'down' && selectedOperation < operations.length - 1) { setSelectedOperation(selectedOperation + 1); } }} - onSelect={(op) => { + onSelect={op => { console.clear(); setExecutingOperation(op.key as OperationType); }} @@ -466,11 +456,16 @@ const ListBlueprintsUI: React.FC<{ render: (blueprint: any, index: number, isSelected: boolean) => { const statusDisplay = getStatusDisplay(blueprint.status); return ( - + {statusDisplay.icon}{' '} ); - } + }, }, { key: 'id', @@ -482,11 +477,17 @@ const ListBlueprintsUI: React.FC<{ const truncated = value.slice(0, width - 1); const padded = truncated.padEnd(width, ' '); return ( - + {padded} ); - } + }, }, { key: 'statusText', @@ -497,18 +498,20 @@ const ListBlueprintsUI: React.FC<{ const truncated = statusDisplay.text.slice(0, statusTextWidth); const padded = truncated.padEnd(statusTextWidth, ' '); return ( - + {padded} ); - } + }, }, - createTextColumn( - 'name', - 'Name', - (blueprint: any) => blueprint.name || '(unnamed)', - { width: nameWidth } - ), + createTextColumn('name', 'Name', (blueprint: any) => blueprint.name || '(unnamed)', { + width: nameWidth, + }), createTextColumn( 'description', 'Description', diff --git a/src/commands/devbox/delete.tsx b/src/commands/devbox/delete.tsx index d6cba5d2..b30d1a32 100644 --- a/src/commands/devbox/delete.tsx +++ b/src/commands/devbox/delete.tsx @@ -33,12 +33,7 @@ const DeleteDevboxUI: React.FC<{ id: string }> = ({ id }) => { <>
{loading && } - {success && ( - - )} + {success && } {error && } ); diff --git a/src/commands/devbox/exec.tsx b/src/commands/devbox/exec.tsx index d4280f4e..eab9ec3b 100644 --- a/src/commands/devbox/exec.tsx +++ b/src/commands/devbox/exec.tsx @@ -6,10 +6,7 @@ import { SpinnerComponent } from '../../components/Spinner.js'; import { ErrorMessage } from '../../components/ErrorMessage.js'; import { colors } from '../../utils/theme.js'; -const ExecCommandUI: React.FC<{ id: string; command: string[] }> = ({ - id, - command, -}) => { +const ExecCommandUI: React.FC<{ id: string; command: string[] }> = ({ id, command }) => { const [loading, setLoading] = React.useState(true); const [output, setOutput] = React.useState(''); const [error, setError] = React.useState(null); @@ -34,10 +31,7 @@ const ExecCommandUI: React.FC<{ id: string; command: string[] }> = ({ return ( <> -
+
{loading && } {!loading && !error && ( diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index d03b7ace..56b5075a 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -83,27 +83,93 @@ const ListDevboxesUI: React.FC<{ // Name width is flexible and uses remaining space let nameWidth = 15; if (terminalWidth >= 120) { - const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - capabilitiesWidth - tagWidth - 12; + const remainingWidth = + terminalWidth - + fixedWidth - + statusIconWidth - + idWidth - + statusTextWidth - + timeWidth - + capabilitiesWidth - + tagWidth - + 12; nameWidth = Math.max(15, remainingWidth); } else if (terminalWidth >= 110) { - const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - tagWidth - 10; + const remainingWidth = + terminalWidth - + fixedWidth - + statusIconWidth - + idWidth - + statusTextWidth - + timeWidth - + tagWidth - + 10; nameWidth = Math.max(12, remainingWidth); } else { - const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10; + const remainingWidth = + terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10; nameWidth = Math.max(8, remainingWidth); } // Define allOperations const allOperations = [ { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, + { + key: 'exec', + label: 'Execute Command', + color: colors.success, + icon: figures.play, + shortcut: 'e', + }, + { + key: 'upload', + label: 'Upload File', + color: colors.success, + icon: figures.arrowUp, + shortcut: 'u', + }, + { + key: 'snapshot', + label: 'Create Snapshot', + color: colors.warning, + icon: figures.circleFilled, + shortcut: 'n', + }, + { + key: 'ssh', + label: 'SSH onto the box', + color: colors.primary, + icon: figures.arrowRight, + shortcut: 's', + }, + { + key: 'tunnel', + label: 'Open Tunnel', + color: colors.secondary, + icon: figures.pointerSmall, + shortcut: 't', + }, + { + key: 'suspend', + label: 'Suspend Devbox', + color: colors.warning, + icon: figures.squareSmallFilled, + shortcut: 'p', + }, + { + key: 'resume', + label: 'Resume Devbox', + color: colors.success, + icon: figures.play, + shortcut: 'r', + }, + { + key: 'delete', + label: 'Shutdown Devbox', + color: colors.error, + icon: figures.cross, + shortcut: 'd', + }, ]; // Check if we need to focus on a specific devbox after returning from SSH @@ -149,7 +215,8 @@ const ListDevboxesUI: React.FC<{ const pageDevboxes: any[] = []; // Get starting_after cursor from previous page's last ID - const startingAfter = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined; + const startingAfter = + currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined; // Build query params const queryParams: any = { @@ -159,7 +226,15 @@ const ListDevboxesUI: React.FC<{ queryParams.starting_after = startingAfter; } if (status) { - queryParams.status = status as 'provisioning' | 'initializing' | 'running' | 'suspending' | 'suspended' | 'resuming' | 'failure' | 'shutdown'; + queryParams.status = status as + | 'provisioning' + | 'initializing' + | 'running' + | 'suspending' + | 'suspended' + | 'resuming' + | 'failure' + | 'shutdown'; } if (searchQuery) { queryParams.search = searchQuery; @@ -194,7 +269,7 @@ const ListDevboxesUI: React.FC<{ } // Update devboxes for current page - setDevboxes((prev) => { + setDevboxes(prev => { const hasChanged = JSON.stringify(prev) !== JSON.stringify(pageDevboxes); return hasChanged ? pageDevboxes : prev; }); @@ -234,7 +309,7 @@ const ListDevboxesUI: React.FC<{ } const interval = setInterval(() => { - setRefreshIcon((prev) => (prev + 1) % 10); + setRefreshIcon(prev => (prev + 1) % 10); }, 80); return () => clearInterval(interval); }, [showDetails, showCreate, showActions]); @@ -305,7 +380,11 @@ const ListDevboxesUI: React.FC<{ setSelectedIndex(selectedIndex - 1); } else if (key.downArrow && selectedIndex < pageDevboxes - 1) { setSelectedIndex(selectedIndex + 1); - } else if ((input === 'n' || key.rightArrow) && !isNavigating.current && currentPage < totalPages - 1) { + } else if ( + (input === 'n' || key.rightArrow) && + !isNavigating.current && + currentPage < totalPages - 1 + ) { setCurrentPage(currentPage + 1); setSelectedIndex(0); } else if ((input === 'p' || key.leftArrow) && !isNavigating.current && currentPage > 0) { @@ -379,27 +458,29 @@ const ListDevboxesUI: React.FC<{ const endIndex = startIndex + currentDevboxes.length; // Filter operations based on devbox status - const operations = selectedDevbox ? allOperations.filter(op => { - const status = selectedDevbox.status; + const operations = selectedDevbox + ? allOperations.filter(op => { + const status = selectedDevbox.status; - // When suspended: logs and resume - if (status === 'suspended') { - return op.key === 'resume' || op.key === 'logs'; - } + // When suspended: logs and resume + if (status === 'suspended') { + return op.key === 'resume' || op.key === 'logs'; + } - // When not running (shutdown, failure, etc): only logs - if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { - return op.key === 'logs'; - } + // When not running (shutdown, failure, etc): only logs + if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { + return op.key === 'logs'; + } - // When running: everything except resume - if (status === 'running') { - return op.key !== 'resume'; - } + // When running: everything except resume + if (status === 'running') { + return op.key !== 'resume'; + } - // Default for transitional states (provisioning, initializing) - return op.key === 'logs' || op.key === 'delete'; - }) : allOperations; + // Default for transitional states (provisioning, initializing) + return op.key === 'logs' || op.key === 'delete'; + }) + : allOperations; // Create view if (showCreate) { @@ -408,7 +489,7 @@ const ListDevboxesUI: React.FC<{ onBack={() => { setShowCreate(false); }} - onCreate={(devbox) => { + onCreate={devbox => { // Refresh the list after creation setShowCreate(false); // The list will auto-refresh via the polling effect @@ -429,7 +510,7 @@ const ListDevboxesUI: React.FC<{ }} breadcrumbItems={[ { label: 'Devboxes' }, - { label: selectedDevbox.name || selectedDevbox.id, active: true } + { label: selectedDevbox.name || selectedDevbox.id, active: true }, ]} initialOperation={selectedOp?.key} skipOperationsMenu={true} @@ -453,9 +534,7 @@ const ListDevboxesUI: React.FC<{ if (showPopup && selectedDevbox) { return ( <> - + {!initialLoading && !error && devboxes.length > 0 && ( <>
+ {statusDisplay.icon}{' '} ); - } + }, }, - createTextColumn( - 'id', - 'ID', - (devbox: any) => devbox.id, - { width: idWidth, color: colors.textDim, dimColor: true, bold: false } - ), + createTextColumn('id', 'ID', (devbox: any) => devbox.id, { + width: idWidth, + color: colors.textDim, + dimColor: true, + bold: false, + }), { key: 'statusText', label: 'Status', @@ -495,42 +579,69 @@ const ListDevboxesUI: React.FC<{ const padded = truncated.padEnd(statusTextWidth, ' '); return ( - + {padded} ); - } + }, }, - createTextColumn( - 'name', - 'Name', - (devbox: any) => devbox.name || '', - { width: nameWidth, dimColor: true } - ), + createTextColumn('name', 'Name', (devbox: any) => devbox.name || '', { + width: nameWidth, + dimColor: true, + }), createTextColumn( 'capabilities', 'Capabilities', (devbox: any) => { - const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; + const hasCapabilities = + devbox.capabilities && + devbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; return hasCapabilities ? `[${devbox.capabilities .filter((c: string) => c !== 'unknown') - .map((c: string) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c) + .map((c: string) => + c === 'computer_usage' + ? 'comp' + : c === 'browser_usage' + ? 'browser' + : c === 'docker_in_docker' + ? 'docker' + : c + ) .join(',')}]` : ''; }, - { width: capabilitiesWidth, color: colors.info, dimColor: true, bold: false, visible: showCapabilities } + { + width: capabilitiesWidth, + color: colors.info, + dimColor: true, + bold: false, + visible: showCapabilities, + } ), createTextColumn( 'tags', 'Tags', - (devbox: any) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', - { width: tagWidth, color: colors.warning, dimColor: true, bold: false, visible: showTags } + (devbox: any) => + devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', + { + width: tagWidth, + color: colors.warning, + dimColor: true, + bold: false, + visible: showTags, + } ), createTextColumn( 'created', 'Created', - (devbox: any) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', + (devbox: any) => + devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} @@ -555,9 +666,7 @@ const ListDevboxesUI: React.FC<{ if (initialLoading) { return ( <> - + ); @@ -566,9 +675,7 @@ const ListDevboxesUI: React.FC<{ if (error) { return ( <> - + ); @@ -577,9 +684,7 @@ const ListDevboxesUI: React.FC<{ // List view with data (always show, even if empty) return ( <> - + {currentDevboxes && currentDevboxes.length >= 0 && ( <> {searchMode && ( @@ -595,14 +700,22 @@ const ListDevboxesUI: React.FC<{ setSelectedIndex(0); }} /> - [Esc to cancel] + + {' '} + [Esc to cancel] + )} {!searchMode && searchQuery && ( {figures.info} Searching for: - {searchQuery} - ({totalCount} results) [/ to edit, Esc to clear] + + {searchQuery} + + + {' '} + ({totalCount} results) [/ to edit, Esc to clear] + )}
+ {statusDisplay.icon}{' '} ); - } + }, }, - createTextColumn( - 'id', - 'ID', - (devbox: any) => devbox.id, - { width: idWidth, color: colors.textDim, dimColor: true, bold: false } - ), + createTextColumn('id', 'ID', (devbox: any) => devbox.id, { + width: idWidth, + color: colors.textDim, + dimColor: true, + bold: false, + }), { key: 'statusText', label: 'Status', @@ -643,42 +761,68 @@ const ListDevboxesUI: React.FC<{ const padded = truncated.padEnd(statusTextWidth, ' '); return ( - + {padded} ); - } + }, }, - createTextColumn( - 'name', - 'Name', - (devbox: any) => devbox.name || '', - { width: nameWidth } - ), + createTextColumn('name', 'Name', (devbox: any) => devbox.name || '', { + width: nameWidth, + }), createTextColumn( 'capabilities', 'Capabilities', (devbox: any) => { - const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; + const hasCapabilities = + devbox.capabilities && + devbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; return hasCapabilities ? `[${devbox.capabilities .filter((c: string) => c !== 'unknown') - .map((c: string) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c) + .map((c: string) => + c === 'computer_usage' + ? 'comp' + : c === 'browser_usage' + ? 'browser' + : c === 'docker_in_docker' + ? 'docker' + : c + ) .join(',')}]` : ''; }, - { width: capabilitiesWidth, color: colors.info, dimColor: true, bold: false, visible: showCapabilities } + { + width: capabilitiesWidth, + color: colors.info, + dimColor: true, + bold: false, + visible: showCapabilities, + } ), createTextColumn( 'tags', 'Tags', - (devbox: any) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', - { width: tagWidth, color: colors.warning, dimColor: true, bold: false, visible: showTags } + (devbox: any) => + devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', + { + width: tagWidth, + color: colors.warning, + dimColor: true, + bold: false, + visible: showTags, + } ), createTextColumn( 'created', 'Created', - (devbox: any) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', + (devbox: any) => + devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ]} @@ -689,21 +833,33 @@ const ListDevboxesUI: React.FC<{ {figures.hamburger} {totalCount} - total + + {' '} + total + {totalPages > 1 && ( <> - + + {' '} + •{' '} + Page {currentPage + 1} of {totalPages} )} - + + {' '} + •{' '} + Showing {startIndex + 1}-{endIndex} of {totalCount} {hasMore && ( - (more available) + + {' '} + (more available) + )} {refreshing ? ( @@ -711,9 +867,7 @@ const ListDevboxesUI: React.FC<{ {['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10]} ) : ( - - {figures.circleFilled} - + {figures.circleFilled} )} @@ -725,14 +879,16 @@ const ListDevboxesUI: React.FC<{ {totalPages > 1 && ( - {' '}• {figures.arrowLeft}{figures.arrowRight} Page + {' '} + • {figures.arrowLeft} + {figures.arrowRight} Page )} - {' '}• [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [Esc] Back + {' '} + • [Enter] Details • [a] Actions • [c] Create • [/] Search • [o] Browser • [Esc] Back - )} @@ -759,7 +915,7 @@ export async function listDevboxes(options: ListOptions, focusDevboxId?: string) { + onSSHRequest={config => { sshSessionConfig = config; }} /> diff --git a/src/commands/devbox/upload.tsx b/src/commands/devbox/upload.tsx index 6461e89c..16f43e65 100644 --- a/src/commands/devbox/upload.tsx +++ b/src/commands/devbox/upload.tsx @@ -58,13 +58,7 @@ const UploadFileUI: React.FC<{ ); }; -export async function uploadFile( - id: string, - file: string, - options: UploadOptions -) { - const { waitUntilExit } = render( - - ); +export async function uploadFile(id: string, file: string, options: UploadOptions) { + const { waitUntilExit } = render(); await waitUntilExit(); } diff --git a/src/commands/mcp-http.ts b/src/commands/mcp-http.ts index 4d422f0e..045e69ab 100644 --- a/src/commands/mcp-http.ts +++ b/src/commands/mcp-http.ts @@ -24,12 +24,12 @@ export async function startMcpHttpServer(port?: number) { env, }); - serverProcess.on('error', (error) => { + serverProcess.on('error', error => { console.error('Failed to start MCP HTTP server:', error); process.exit(1); }); - serverProcess.on('exit', (code) => { + serverProcess.on('exit', code => { if (code !== 0) { console.error(`MCP HTTP server exited with code ${code}`); process.exit(code || 1); diff --git a/src/commands/mcp-install.ts b/src/commands/mcp-install.ts index 4a9e054f..5343b15b 100644 --- a/src/commands/mcp-install.ts +++ b/src/commands/mcp-install.ts @@ -8,7 +8,13 @@ function getClaudeConfigPath(): string { const plat = platform(); if (plat === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + return join( + homedir(), + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json' + ); } else if (plat === 'win32') { const appData = process.env.APPDATA; if (!appData) { @@ -77,8 +83,8 @@ export async function installMcpConfig() { // For non-interactive mode, just exit if (process.stdin.isTTY) { - const response = await new Promise((resolve) => { - process.stdin.once('data', (data) => { + const response = await new Promise(resolve => { + process.stdin.once('data', data => { resolve(data.toString().trim().toLowerCase()); }); }); @@ -110,20 +116,25 @@ export async function installMcpConfig() { console.log('1. Restart Claude Desktop completely (quit and reopen)'); console.log('2. Ask Claude: "List my devboxes" or "What Runloop tools do you have?"'); console.log('\n💡 Tip: Make sure you\'ve run "rln auth" to configure your API key first!'); - } catch (error: any) { console.error('\n❌ Error installing MCP configuration:', error.message); console.error('\n💡 You can manually add this configuration to your Claude Desktop config:'); console.error(`\nFile location: ${getClaudeConfigPath()}`); console.error('\nConfiguration to add:'); - console.error(JSON.stringify({ - mcpServers: { - runloop: { - command: 'rln', - args: ['mcp', 'start'], + console.error( + JSON.stringify( + { + mcpServers: { + runloop: { + command: 'rln', + args: ['mcp', 'start'], + }, + }, }, - }, - }, null, 2)); + null, + 2 + ) + ); process.exit(1); } } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index f8413288..1d307279 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -16,12 +16,12 @@ export async function startMcpServer() { stdio: 'inherit', // Pass through stdin/stdout/stderr }); - serverProcess.on('error', (error) => { + serverProcess.on('error', error => { console.error('Failed to start MCP server:', error); process.exit(1); }); - serverProcess.on('exit', (code) => { + serverProcess.on('exit', code => { if (code !== 0) { console.error(`MCP server exited with code ${code}`); process.exit(code || 1); diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index 6796c458..135a345c 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -48,7 +48,9 @@ const App: React.FC = ({ onSSHRequest, initialScreen = 'menu', focusDe focusDevboxId={focusDevboxId} /> )} - {currentScreen === 'blueprints' && } + {currentScreen === 'blueprints' && ( + + )} {currentScreen === 'snapshots' && } ); @@ -69,7 +71,7 @@ export async function runMainMenu(initialScreen: Screen = 'menu', focusDevboxId? try { const { waitUntilExit } = render( { + onSSHRequest={config => { sshSessionConfig = config; }} initialScreen={currentInitialScreen} diff --git a/src/commands/snapshot/create.tsx b/src/commands/snapshot/create.tsx index b111dca1..480c80d4 100644 --- a/src/commands/snapshot/create.tsx +++ b/src/commands/snapshot/create.tsx @@ -117,9 +117,6 @@ const CreateSnapshotUI: React.FC<{ export async function createSnapshot(devboxId: string, options: CreateOptions) { console.clear(); - const { waitUntilExit } = render( - , - - ); + const { waitUntilExit } = render(); await waitUntilExit(); } diff --git a/src/commands/snapshot/delete.tsx b/src/commands/snapshot/delete.tsx index 79228039..6c831799 100644 --- a/src/commands/snapshot/delete.tsx +++ b/src/commands/snapshot/delete.tsx @@ -33,12 +33,7 @@ const DeleteSnapshotUI: React.FC<{ id: string }> = ({ id }) => { <>
{loading && } - {success && ( - - )} + {success && } {error && } ); diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 95460936..7dcc2d3f 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -56,28 +56,27 @@ const ListSnapshotsUI: React.FC<{ return allSnapshots; }, columns: [ - createTextColumn( - 'id', - 'ID', - (snapshot: any) => snapshot.id, - { width: idWidth, color: colors.textDim, dimColor: true, bold: false } - ), - createTextColumn( - 'name', - 'Name', - (snapshot: any) => snapshot.name || '(unnamed)', - { width: nameWidth } - ), - createTextColumn( - 'devbox', - 'Devbox', - (snapshot: any) => snapshot.source_devbox_id || '', - { width: devboxWidth, color: colors.primary, dimColor: true, bold: false, visible: showDevboxId } - ), + createTextColumn('id', 'ID', (snapshot: any) => snapshot.id, { + width: idWidth, + color: colors.textDim, + dimColor: true, + bold: false, + }), + createTextColumn('name', 'Name', (snapshot: any) => snapshot.name || '(unnamed)', { + width: nameWidth, + }), + createTextColumn('devbox', 'Devbox', (snapshot: any) => snapshot.source_devbox_id || '', { + width: devboxWidth, + color: colors.primary, + dimColor: true, + bold: false, + visible: showDevboxId, + }), createTextColumn( 'created', 'Created', - (snapshot: any) => snapshot.create_time_ms ? formatTimeAgo(snapshot.create_time_ms) : '', + (snapshot: any) => + snapshot.create_time_ms ? formatTimeAgo(snapshot.create_time_ms) : '', { width: timeWidth, color: colors.textDim, dimColor: true, bold: false } ), ], diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index e45f1f8e..736c2f63 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -69,7 +69,11 @@ export const ActionsPopup: React.FC = ({ {borderTop} {lines.map((line, i) => ( - {chalk.cyan('│')}{line}{chalk.cyan('│')} + + {chalk.cyan('│')} + {line} + {chalk.cyan('│')} + ))} {borderBottom} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 897e7aec..81670fb1 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -8,7 +8,7 @@ export const Banner: React.FC = React.memo(() => { - + {/* .ai */} diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index cd83b6e7..f0bb625e 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -18,16 +18,33 @@ export const Breadcrumb: React.FC = React.memo(({ items }) => { return ( - rl - {isDevEnvironment && (dev)} - + + rl + + {isDevEnvironment && ( + + {' '} + (dev) + + )} + + {' '} + ›{' '} + {items.map((item, index) => ( - + {item.label} {index < items.length - 1 && ( - + + {' '} + ›{' '} + )} ))} diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 9245103c..9d6fea7a 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -31,7 +31,7 @@ export const DetailView: React.FC = ({ sections }) => { {section.items.map((item, itemIndex) => ( - {item.label}: {item.value} + {item.label}: {item.value} ))} @@ -62,19 +62,25 @@ export function buildDetailSections( }; } ): DetailSection[] { - return Object.entries(config).map(([sectionName, sectionConfig]) => ({ - title: sectionName, - items: sectionConfig.fields - .map((field) => { - const value = data[field.key]; - if (value === undefined || value === null) return null; + return Object.entries(config) + .map(([sectionName, sectionConfig]) => ({ + title: sectionName, + items: sectionConfig.fields + .map(field => { + const value = data[field.key]; + if (value === undefined || value === null) return null; - return { - label: field.label, - value: field.formatter ? field.formatter(value) : String(value), - color: field.color, - }; - }) - .filter(Boolean) as Array<{ label: string; value: string | React.ReactNode; color?: string }>, - })).filter((section) => section.items.length > 0); + return { + label: field.label, + value: field.formatter ? field.formatter(value) : String(value), + color: field.color, + }; + }) + .filter(Boolean) as Array<{ + label: string; + value: string | React.ReactNode; + color?: string; + }>, + })) + .filter(section => section.items.length > 0); } diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 4f36ccc6..40fedf96 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -11,7 +11,17 @@ import { Breadcrumb } from './Breadcrumb.js'; import type { SSHSessionConfig } from '../utils/sshSession.js'; import { colors } from '../utils/theme.js'; -type Operation = 'exec' | 'upload' | 'snapshot' | 'ssh' | 'logs' | 'tunnel' | 'suspend' | 'resume' | 'delete' | null; +type Operation = + | 'exec' + | 'upload' + | 'snapshot' + | 'ssh' + | 'logs' + | 'tunnel' + | 'suspend' + | 'resume' + | 'delete' + | null; interface DevboxActionsMenuProps { devbox: any; @@ -26,10 +36,7 @@ interface DevboxActionsMenuProps { export const DevboxActionsMenu: React.FC = ({ devbox, onBack, - breadcrumbItems = [ - { label: 'Devboxes' }, - { label: devbox.name || devbox.id, active: true } - ], + breadcrumbItems = [{ label: 'Devboxes' }, { label: devbox.name || devbox.id, active: true }], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, @@ -39,7 +46,9 @@ export const DevboxActionsMenu: React.FC = ({ const { stdout } = useStdout(); const [loading, setLoading] = React.useState(false); const [selectedOperation, setSelectedOperation] = React.useState(initialOperationIndex); - const [executingOperation, setExecutingOperation] = React.useState(initialOperation as Operation || null); + const [executingOperation, setExecutingOperation] = React.useState( + (initialOperation as Operation) || null + ); const [operationInput, setOperationInput] = React.useState(''); const [operationResult, setOperationResult] = React.useState(null); const [operationError, setOperationError] = React.useState(null); @@ -50,38 +59,88 @@ export const DevboxActionsMenu: React.FC = ({ const allOperations = [ { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, + { + key: 'exec', + label: 'Execute Command', + color: colors.success, + icon: figures.play, + shortcut: 'e', + }, + { + key: 'upload', + label: 'Upload File', + color: colors.success, + icon: figures.arrowUp, + shortcut: 'u', + }, + { + key: 'snapshot', + label: 'Create Snapshot', + color: colors.warning, + icon: figures.circleFilled, + shortcut: 'n', + }, + { + key: 'ssh', + label: 'SSH onto the box', + color: colors.primary, + icon: figures.arrowRight, + shortcut: 's', + }, + { + key: 'tunnel', + label: 'Open Tunnel', + color: colors.secondary, + icon: figures.pointerSmall, + shortcut: 't', + }, + { + key: 'suspend', + label: 'Suspend Devbox', + color: colors.warning, + icon: figures.squareSmallFilled, + shortcut: 'p', + }, + { + key: 'resume', + label: 'Resume Devbox', + color: colors.success, + icon: figures.play, + shortcut: 'r', + }, + { + key: 'delete', + label: 'Shutdown Devbox', + color: colors.error, + icon: figures.cross, + shortcut: 'd', + }, ]; // Filter operations based on devbox status - const operations = devbox ? allOperations.filter(op => { - const status = devbox.status; + const operations = devbox + ? allOperations.filter(op => { + const status = devbox.status; - // When suspended: logs and resume - if (status === 'suspended') { - return op.key === 'resume' || op.key === 'logs'; - } + // When suspended: logs and resume + if (status === 'suspended') { + return op.key === 'resume' || op.key === 'logs'; + } - // When not running (shutdown, failure, etc): only logs - if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { - return op.key === 'logs'; - } + // When not running (shutdown, failure, etc): only logs + if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { + return op.key === 'logs'; + } - // When running: everything except resume - if (status === 'running') { - return op.key !== 'resume'; - } + // When running: everything except resume + if (status === 'running') { + return op.key !== 'resume'; + } - // Default for transitional states (provisioning, initializing) - return op.key === 'logs' || op.key === 'delete'; - }) : allOperations; + // Default for transitional states (provisioning, initializing) + return op.key === 'logs' || op.key === 'delete'; + }) + : allOperations; // Auto-execute operations that don't need input React.useEffect(() => { @@ -121,25 +180,64 @@ export const DevboxActionsMenu: React.FC = ({ setExecScroll(0); setCopyStatus(null); } - } else if ((key.upArrow || input === 'k') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + (key.upArrow || input === 'k') && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { setExecScroll(Math.max(0, execScroll - 1)); - } else if ((key.downArrow || input === 'j') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + (key.downArrow || input === 'j') && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { setExecScroll(execScroll + 1); - } else if (key.pageUp && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + key.pageUp && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { setExecScroll(Math.max(0, execScroll - 10)); - } else if (key.pageDown && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + key.pageDown && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { setExecScroll(execScroll + 10); - } else if (input === 'g' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + input === 'g' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { setExecScroll(0); - } else if (input === 'G' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { - const lines = [...((operationResult as any).stdout || '').split('\n'), ...((operationResult as any).stderr || '').split('\n')]; + } else if ( + input === 'G' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { + const lines = [ + ...((operationResult as any).stdout || '').split('\n'), + ...((operationResult as any).stderr || '').split('\n'), + ]; const terminalHeight = stdout?.rows || 30; const viewportHeight = Math.max(10, terminalHeight - 15); const maxScroll = Math.max(0, lines.length - viewportHeight); setExecScroll(maxScroll); - } else if (input === 'c' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + } else if ( + input === 'c' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { // Copy exec output to clipboard - const output = ((operationResult as any).stdout || '') + ((operationResult as any).stderr || ''); + const output = + ((operationResult as any).stdout || '') + ((operationResult as any).stderr || ''); const copyToClipboard = async (text: string) => { const { spawn } = await import('child_process'); @@ -163,7 +261,7 @@ export const DevboxActionsMenu: React.FC = ({ proc.stdin.write(text); proc.stdin.end(); - proc.on('exit', (code) => { + proc.on('exit', code => { if (code === 0) { setCopyStatus('Copied to clipboard!'); setTimeout(() => setCopyStatus(null), 2000); @@ -180,36 +278,79 @@ export const DevboxActionsMenu: React.FC = ({ }; copyToClipboard(output); - } else if ((key.upArrow || input === 'k') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + (key.upArrow || input === 'k') && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsScroll(Math.max(0, logsScroll - 1)); - } else if ((key.downArrow || input === 'j') && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + (key.downArrow || input === 'j') && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsScroll(logsScroll + 1); - } else if (key.pageUp && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + key.pageUp && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsScroll(Math.max(0, logsScroll - 10)); - } else if (key.pageDown && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + key.pageDown && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsScroll(logsScroll + 10); - } else if (input === 'g' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + input === 'g' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsScroll(0); - } else if (input === 'G' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + input === 'G' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { const logs = (operationResult as any).__logs || []; const terminalHeight = stdout?.rows || 30; const viewportHeight = Math.max(10, terminalHeight - 10); const maxScroll = Math.max(0, logs.length - viewportHeight); setLogsScroll(maxScroll); - } else if (input === 'w' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + input === 'w' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { setLogsWrapMode(!logsWrapMode); - } else if (input === 'c' && operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + } else if ( + input === 'c' && + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { // Copy logs to clipboard const logs = (operationResult as any).__logs || []; - const logsText = logs.map((log: any) => { - const time = new Date(log.timestamp_ms).toLocaleString(); - const level = log.level || 'INFO'; - const source = log.source || 'exec'; - const message = log.message || ''; - const cmd = log.cmd ? `[${log.cmd}] ` : ''; - const exitCode = log.exit_code !== null && log.exit_code !== undefined ? `(${log.exit_code}) ` : ''; - return `${time} ${level}/${source} ${exitCode}${cmd}${message}`; - }).join('\n'); + const logsText = logs + .map((log: any) => { + const time = new Date(log.timestamp_ms).toLocaleString(); + const level = log.level || 'INFO'; + const source = log.source || 'exec'; + const message = log.message || ''; + const cmd = log.cmd ? `[${log.cmd}] ` : ''; + const exitCode = + log.exit_code !== null && log.exit_code !== undefined ? `(${log.exit_code}) ` : ''; + return `${time} ${level}/${source} ${exitCode}${cmd}${message}`; + }) + .join('\n'); const copyToClipboard = async (text: string) => { const { spawn } = await import('child_process'); @@ -233,7 +374,7 @@ export const DevboxActionsMenu: React.FC = ({ proc.stdin.write(text); proc.stdin.end(); - proc.on('exit', (code) => { + proc.on('exit', code => { if (code === 0) { setCopyStatus('Copied to clipboard!'); setTimeout(() => setCopyStatus(null), 2000); @@ -340,7 +481,7 @@ export const DevboxActionsMenu: React.FC = ({ sshUser, url: sshKey.url, devboxId: devbox.id, - devboxName: devbox.name || devbox.id + devboxName: devbox.name || devbox.id, }; // Notify parent that SSH is requested @@ -367,14 +508,16 @@ export const DevboxActionsMenu: React.FC = ({ case 'tunnel': const port = parseInt(operationInput); if (isNaN(port) || port < 1 || port > 65535) { - setOperationError(new Error('Invalid port number. Please enter a port between 1 and 65535.')); + setOperationError( + new Error('Invalid port number. Please enter a port between 1 and 65535.') + ); } else { const tunnel = await client.devboxes.createTunnel(devbox.id, { port }); setOperationResult( `Tunnel created!\n\n` + - `Local Port: ${port}\n` + - `Public URL: ${tunnel.url}\n\n` + - `You can now access port ${port} on the devbox via:\n${tunnel.url}` + `Local Port: ${port}\n` + + `Public URL: ${tunnel.url}\n\n` + + `You can now access port ${port} on the devbox via:\n${tunnel.url}` ); } break; @@ -401,12 +544,16 @@ export const DevboxActionsMenu: React.FC = ({ } }; - const operationLabel = operations.find((o) => o.key === executingOperation)?.label || 'Operation'; + const operationLabel = operations.find(o => o.key === executingOperation)?.label || 'Operation'; // Operation result display if (operationResult || operationError) { // Check for custom exec rendering - if (operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'exec') { + if ( + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'exec' + ) { const command = (operationResult as any).command || ''; const stdout = (operationResult as any).stdout || ''; const stderr = (operationResult as any).stderr || ''; @@ -431,22 +578,36 @@ export const DevboxActionsMenu: React.FC = ({ {/* Command header */} - + - {figures.play} Command: + + {figures.play} Command: + {command} - Exit Code: - {exitCode} + + Exit Code:{' '} + + + {exitCode} + {/* Output display */} {allLines.length === 0 && ( - No output + + No output + )} {visibleLines.map((line: string, index: number) => { const actualIndex = actualScroll + index; @@ -477,31 +638,53 @@ export const DevboxActionsMenu: React.FC = ({ {figures.hamburger} {allLines.length} - lines + + {' '} + lines + {allLines.length > 0 && ( <> - - Viewing {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, allLines.length)} of {allLines.length} + {' '} + •{' '} + + + Viewing {actualScroll + 1}- + {Math.min(actualScroll + viewportHeight, allLines.length)} of {allLines.length} )} {stdout && ( <> - - stdout: {stdoutLines.length} lines + + {' '} + •{' '} + + + stdout: {stdoutLines.length} lines + )} {stderr && ( <> - - stderr: {stderrLines.length} lines + + {' '} + •{' '} + + + stderr: {stderrLines.length} lines + )} {copyStatus && ( <> - - {copyStatus} + + {' '} + •{' '} + + + {copyStatus} + )} @@ -509,7 +692,9 @@ export const DevboxActionsMenu: React.FC = ({ {/* Help bar */} - {figures.arrowUp}{figures.arrowDown} Navigate • [g] Top • [G] Bottom • [c] Copy • [Enter], [q], or [esc] Back + {figures.arrowUp} + {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [c] Copy • [Enter], [q], or + [esc] Back @@ -517,7 +702,11 @@ export const DevboxActionsMenu: React.FC = ({ } // Check for custom logs rendering - if (operationResult && typeof operationResult === 'object' && (operationResult as any).__customRender === 'logs') { + if ( + operationResult && + typeof operationResult === 'object' && + (operationResult as any).__customRender === 'logs' + ) { const logs = (operationResult as any).__logs || []; const totalCount = (operationResult as any).__totalCount || 0; @@ -540,8 +729,11 @@ export const DevboxActionsMenu: React.FC = ({ const level = log.level ? log.level[0].toUpperCase() : 'I'; const source = log.source ? log.source.substring(0, 8) : 'exec'; const fullMessage = log.message || ''; - const cmd = log.cmd ? `[${log.cmd.substring(0, 40)}${log.cmd.length > 40 ? '...' : ''}] ` : ''; - const exitCode = log.exit_code !== null && log.exit_code !== undefined ? `(${log.exit_code}) ` : ''; + const cmd = log.cmd + ? `[${log.cmd.substring(0, 40)}${log.cmd.length > 40 ? '...' : ''}] ` + : ''; + const exitCode = + log.exit_code !== null && log.exit_code !== undefined ? `(${log.exit_code}) ` : ''; let levelColor: string = colors.textDim; if (level === 'E') levelColor = colors.error; @@ -551,31 +743,52 @@ export const DevboxActionsMenu: React.FC = ({ if (logsWrapMode) { return ( - {time} + + {time} + - {level} - /{source} + + {level} + + + /{source} + {exitCode && {exitCode}} - {cmd && {cmd}} + {cmd && ( + + {cmd} + + )} {fullMessage} ); } else { const metadataWidth = 11 + 1 + 1 + 1 + 8 + 1 + exitCode.length + cmd.length + 6; const availableMessageWidth = Math.max(20, terminalWidth - metadataWidth); - const truncatedMessage = fullMessage.length > availableMessageWidth - ? fullMessage.substring(0, availableMessageWidth - 3) + '...' - : fullMessage; + const truncatedMessage = + fullMessage.length > availableMessageWidth + ? fullMessage.substring(0, availableMessageWidth - 3) + '...' + : fullMessage; return ( - {time} + + {time} + - {level} - /{source} + + {level} + + + /{source} + {exitCode && {exitCode}} - {cmd && {cmd}} + {cmd && ( + + {cmd} + + )} {truncatedMessage} ); @@ -598,26 +811,43 @@ export const DevboxActionsMenu: React.FC = ({ {figures.hamburger} {totalCount} - total logs - - Viewing {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, logs.length)} of {logs.length} + {' '} + total logs + + + {' '} + •{' '} + + + Viewing {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, logs.length)} of{' '} + {logs.length} + + + {' '} + •{' '} - {logsWrapMode ? 'Wrap: ON' : 'Wrap: OFF'} {copyStatus && ( <> - - {copyStatus} + + {' '} + •{' '} + + + {copyStatus} + )} - {figures.arrowUp}{figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle Wrap • [c] Copy • [Enter], [q], or [esc] Back + {figures.arrowUp} + {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle Wrap • [c] Copy • + [Enter], [q], or [esc] Back @@ -702,10 +932,10 @@ export const DevboxActionsMenu: React.FC = ({ executingOperation === 'exec' ? 'ls -la' : executingOperation === 'upload' - ? '/path/to/file' - : executingOperation === 'tunnel' - ? '8080' - : 'my-snapshot' + ? '/path/to/file' + : executingOperation === 'tunnel' + ? '8080' + : 'my-snapshot' } /> @@ -725,15 +955,24 @@ export const DevboxActionsMenu: React.FC = ({ <> - {figures.play} Operations + + {figures.play} Operations + {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( - {isSelected ? figures.pointer : ' '} - {op.icon} {op.label} - [{op.shortcut}] + + {isSelected ? figures.pointer : ' '}{' '} + + + {op.icon} {op.label} + + + {' '} + [{op.shortcut}] + ); })} @@ -742,7 +981,8 @@ export const DevboxActionsMenu: React.FC = ({ - {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Select • [q] Back + {figures.arrowUp} + {figures.arrowDown} Navigate • [Enter] Select • [q] Back diff --git a/src/components/DevboxCard.tsx b/src/components/DevboxCard.tsx index f38049a3..4109b889 100644 --- a/src/components/DevboxCard.tsx +++ b/src/components/DevboxCard.tsx @@ -11,12 +11,7 @@ interface DevboxCardProps { index?: number; } -export const DevboxCard: React.FC = ({ - id, - name, - status, - createdAt, -}) => { +export const DevboxCard: React.FC = ({ id, name, status, createdAt }) => { const getStatusDisplay = (status: string) => { switch (status) { case 'running': diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index dbfbd89e..07779971 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -32,7 +32,15 @@ type FormField = interface FormData { name: string; architecture: 'arm64' | 'x86_64'; - resource_size: 'X_SMALL' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'X_LARGE' | 'XX_LARGE' | 'CUSTOM_SIZE' | ''; + resource_size: + | 'X_SMALL' + | 'SMALL' + | 'MEDIUM' + | 'LARGE' + | 'X_LARGE' + | 'XX_LARGE' + | 'CUSTOM_SIZE' + | ''; custom_cpu: string; custom_memory: string; custom_disk: string; @@ -65,7 +73,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); - const baseFields: Array<{ key: FormField; label: string; type: 'text' | 'select' | 'metadata' | 'action' }> = [ + const baseFields: Array<{ + key: FormField; + label: string; + type: 'text' | 'select' | 'metadata' | 'action'; + }> = [ { key: 'create', label: 'Devbox Create', type: 'action' }, { key: 'name', label: 'Name', type: 'text' }, { key: 'architecture', label: 'Architecture', type: 'select' }, @@ -73,7 +85,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr ]; // Add custom resource fields if CUSTOM_SIZE is selected - const customFields: Array<{ key: FormField; label: string; type: 'text' | 'select' | 'metadata' | 'action' }> = + const customFields: Array<{ + key: FormField; + label: string; + type: 'text' | 'select' | 'metadata' | 'action'; + }> = formData.resource_size === 'CUSTOM_SIZE' ? [ { key: 'custom_cpu', label: 'CPU Cores (2-16, even)', type: 'text' }, @@ -82,7 +98,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr ] : []; - const remainingFields: Array<{ key: FormField; label: string; type: 'text' | 'select' | 'metadata' | 'action' }> = [ + const remainingFields: Array<{ + key: FormField; + label: string; + type: 'text' | 'select' | 'metadata' | 'action'; + }> = [ { key: 'keep_alive', label: 'Keep Alive (seconds)', type: 'text' }, { key: 'blueprint_id', label: 'Blueprint ID (optional)', type: 'text' }, { key: 'snapshot_id', label: 'Snapshot ID (optional)', type: 'text' }, @@ -92,9 +112,17 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const fields = [...baseFields, ...customFields, ...remainingFields]; const architectures = ['arm64', 'x86_64']; - const resourceSizes = ['X_SMALL', 'SMALL', 'MEDIUM', 'LARGE', 'X_LARGE', 'XX_LARGE', 'CUSTOM_SIZE']; + const resourceSizes = [ + 'X_SMALL', + 'SMALL', + 'MEDIUM', + 'LARGE', + 'X_LARGE', + 'XX_LARGE', + 'CUSTOM_SIZE', + ]; - const currentFieldIndex = fields.findIndex((f) => f.key === currentField); + const currentFieldIndex = fields.findIndex(f => f.key === currentField); useInput((input, key) => { // Handle result screen @@ -215,7 +243,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr setMetadataInputMode('key'); } - } else if ((input === 'd' || key.delete) && selectedMetadataIndex >= 1 && selectedMetadataIndex <= metadataKeys.length) { + } else if ( + (input === 'd' || key.delete) && + selectedMetadataIndex >= 1 && + selectedMetadataIndex <= metadataKeys.length + ) { // Delete selected item (selectedMetadataIndex - 1 gives array index) const keyToDelete = metadataKeys[selectedMetadataIndex - 1]; const newMetadata = { ...formData.metadata }; @@ -275,7 +307,6 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr } return; } - }); // Validate custom resource configuration @@ -292,7 +323,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr return 'CPU cores must be an even number between 2 and 16'; } - if (formData.custom_memory && (isNaN(memory) || memory < 2 || memory > 64 || memory % 2 !== 0)) { + if ( + formData.custom_memory && + (isNaN(memory) || memory < 2 || memory > 64 || memory % 2 !== 0) + ) { return 'Memory must be an even number between 2 and 64 GB'; } @@ -337,8 +371,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr if (formData.resource_size === 'CUSTOM_SIZE') { if (formData.custom_cpu) launchParameters.custom_cpu_cores = parseInt(formData.custom_cpu); - if (formData.custom_memory) launchParameters.custom_gb_memory = parseInt(formData.custom_memory); - if (formData.custom_disk) launchParameters.custom_disk_size = parseInt(formData.custom_disk); + if (formData.custom_memory) + launchParameters.custom_gb_memory = parseInt(formData.custom_memory); + if (formData.custom_disk) + launchParameters.custom_disk_size = parseInt(formData.custom_disk); } if (formData.keep_alive) { @@ -380,10 +416,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr if (result) { return ( <> - + = ({ onBack, onCr if (error) { return ( <> - + @@ -419,10 +449,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr if (creating) { return ( <> - + ); @@ -431,10 +458,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr // Form screen return ( <> - + {fields.map((field, index) => { @@ -449,7 +473,8 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {isActive && ( - {' '}[Enter to create] + {' '} + [Enter to create] )} @@ -465,15 +490,19 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {isActive ? ( { + onChange={value => { setFormData({ ...formData, [field.key]: value }); }} placeholder={ - field.key === 'name' ? 'my-devbox' : - field.key === 'keep_alive' ? '3600' : - field.key === 'blueprint_id' ? 'bp_xxx' : - field.key === 'snapshot_id' ? 'snap_xxx' : - '' + field.key === 'name' + ? 'my-devbox' + : field.key === 'keep_alive' + ? '3600' + : field.key === 'blueprint_id' + ? 'bp_xxx' + : field.key === 'snapshot_id' + ? 'snap_xxx' + : '' } /> ) : ( @@ -491,11 +520,14 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {isActive ? figures.pointer : ' '} {field.label}: - {' '}{value || '(none)'} + {' '} + {value || '(none)'} {isActive && ( - {' '}[{figures.arrowLeft}{figures.arrowRight} to change] + {' '} + [{figures.arrowLeft} + {figures.arrowRight} to change] )} @@ -513,7 +545,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {Object.keys(formData.metadata).length} item(s) {isActive && ( - [Enter to manage] + + {' '} + [Enter to manage] + )} {Object.keys(formData.metadata).length > 0 && ( @@ -531,20 +566,43 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr const maxIndex = metadataKeys.length + 1; return ( - - {figures.hamburger} Manage Metadata + + + {figures.hamburger} Manage Metadata + {/* Input form - shown when adding or editing */} {metadataInputMode && ( - - + + {selectedMetadataIndex === 0 ? 'Adding New' : 'Editing'} {metadataInputMode === 'key' ? ( <> Key: - + ) : ( Key: {metadataKey || ''} @@ -554,7 +612,11 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {metadataInputMode === 'value' ? ( <> Value: - + ) : ( Value: {metadataValue || ''} @@ -571,7 +633,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {selectedMetadataIndex === 0 ? figures.pointer : ' '}{' '} - + + Add new metadata @@ -587,7 +652,10 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {isSelected ? figures.pointer : ' '}{' '} - + {key}: {formData.metadata[key]} @@ -598,10 +666,15 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {/* Done option */} - + {selectedMetadataIndex === maxIndex ? figures.pointer : ' '}{' '} - + {figures.tick} Done @@ -613,8 +686,7 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr {metadataInputMode ? `[Tab] Switch field • [Enter] ${metadataInputMode === 'key' ? 'Next' : 'Save'} • [esc] Cancel` - : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMetadataIndex === 0 ? 'Add' : selectedMetadataIndex === maxIndex ? 'Done' : 'Edit'} • [d] Delete • [esc] Back` - } + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedMetadataIndex === 0 ? 'Add' : selectedMetadataIndex === maxIndex ? 'Done' : 'Edit'} • [d] Delete • [esc] Back`} @@ -625,19 +697,23 @@ export const DevboxCreatePage: React.FC = ({ onBack, onCr })} - {/* Validation warning */} {formData.resource_size === 'CUSTOM_SIZE' && validateCustomResources() && ( - {figures.cross} Validation Error - {validateCustomResources()} + + {figures.cross} Validation Error + + + {validateCustomResources()} + )} {!inMetadataSection && ( - {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Create • [q] Cancel + {figures.arrowUp} + {figures.arrowDown} Navigate • [Enter] Create • [q] Cancel )} diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 4312494b..3ae8c67f 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -38,7 +38,11 @@ const formatTimeAgo = (timestamp: number): string => { return `${years}y ago`; }; -export const DevboxDetailPage: React.FC = ({ devbox: initialDevbox, onBack, onSSHRequest }) => { +export const DevboxDetailPage: React.FC = ({ + devbox: initialDevbox, + onBack, + onSSHRequest, +}) => { const { stdout } = useStdout(); const [showDetailedInfo, setShowDetailedInfo] = React.useState(false); const [detailScroll, setDetailScroll] = React.useState(0); @@ -49,47 +53,98 @@ export const DevboxDetailPage: React.FC = ({ devbox: init const allOperations = [ { key: 'logs', label: 'View Logs', color: colors.info, icon: figures.info, shortcut: 'l' }, - { key: 'exec', label: 'Execute Command', color: colors.success, icon: figures.play, shortcut: 'e' }, - { key: 'upload', label: 'Upload File', color: colors.success, icon: figures.arrowUp, shortcut: 'u' }, - { key: 'snapshot', label: 'Create Snapshot', color: colors.warning, icon: figures.circleFilled, shortcut: 'n' }, - { key: 'ssh', label: 'SSH onto the box', color: colors.primary, icon: figures.arrowRight, shortcut: 's' }, - { key: 'tunnel', label: 'Open Tunnel', color: colors.secondary, icon: figures.pointerSmall, shortcut: 't' }, - { key: 'suspend', label: 'Suspend Devbox', color: colors.warning, icon: figures.squareSmallFilled, shortcut: 'p' }, - { key: 'resume', label: 'Resume Devbox', color: colors.success, icon: figures.play, shortcut: 'r' }, - { key: 'delete', label: 'Shutdown Devbox', color: colors.error, icon: figures.cross, shortcut: 'd' }, + { + key: 'exec', + label: 'Execute Command', + color: colors.success, + icon: figures.play, + shortcut: 'e', + }, + { + key: 'upload', + label: 'Upload File', + color: colors.success, + icon: figures.arrowUp, + shortcut: 'u', + }, + { + key: 'snapshot', + label: 'Create Snapshot', + color: colors.warning, + icon: figures.circleFilled, + shortcut: 'n', + }, + { + key: 'ssh', + label: 'SSH onto the box', + color: colors.primary, + icon: figures.arrowRight, + shortcut: 's', + }, + { + key: 'tunnel', + label: 'Open Tunnel', + color: colors.secondary, + icon: figures.pointerSmall, + shortcut: 't', + }, + { + key: 'suspend', + label: 'Suspend Devbox', + color: colors.warning, + icon: figures.squareSmallFilled, + shortcut: 'p', + }, + { + key: 'resume', + label: 'Resume Devbox', + color: colors.success, + icon: figures.play, + shortcut: 'r', + }, + { + key: 'delete', + label: 'Shutdown Devbox', + color: colors.error, + icon: figures.cross, + shortcut: 'd', + }, ]; // Filter operations based on devbox status - const operations = selectedDevbox ? allOperations.filter(op => { - const status = selectedDevbox.status; + const operations = selectedDevbox + ? allOperations.filter(op => { + const status = selectedDevbox.status; - // When suspended: logs and resume - if (status === 'suspended') { - return op.key === 'resume' || op.key === 'logs'; - } + // When suspended: logs and resume + if (status === 'suspended') { + return op.key === 'resume' || op.key === 'logs'; + } - // When not running (shutdown, failure, etc): only logs - if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { - return op.key === 'logs'; - } + // When not running (shutdown, failure, etc): only logs + if (status !== 'running' && status !== 'provisioning' && status !== 'initializing') { + return op.key === 'logs'; + } - // When running: everything except resume - if (status === 'running') { - return op.key !== 'resume'; - } + // When running: everything except resume + if (status === 'running') { + return op.key !== 'resume'; + } - // Default for transitional states (provisioning, initializing) - return op.key === 'logs' || op.key === 'delete'; - }) : allOperations; + // Default for transitional states (provisioning, initializing) + return op.key === 'logs' || op.key === 'delete'; + }) + : allOperations; // Memoize time-based values to prevent re-rendering on every tick const formattedCreateTime = React.useMemo( - () => selectedDevbox.create_time_ms ? new Date(selectedDevbox.create_time_ms).toLocaleString() : '', + () => + selectedDevbox.create_time_ms ? new Date(selectedDevbox.create_time_ms).toLocaleString() : '', [selectedDevbox.create_time_ms] ); const createTimeAgo = React.useMemo( - () => selectedDevbox.create_time_ms ? formatTimeAgo(selectedDevbox.create_time_ms) : '', + () => (selectedDevbox.create_time_ms ? formatTimeAgo(selectedDevbox.create_time_ms) : ''), [selectedDevbox.create_time_ms] ); @@ -177,71 +232,184 @@ export const DevboxDetailPage: React.FC = ({ devbox: init const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); // Core Information - lines.push(Devbox Details); - lines.push( ID: {selectedDevbox.id}); - lines.push( Name: {selectedDevbox.name || '(none)'}); - lines.push( Status: {capitalize(selectedDevbox.status)}); - lines.push( Created: {new Date(selectedDevbox.create_time_ms).toLocaleString()}); + lines.push( + + Devbox Details + + ); + lines.push( + + {' '} + ID: {selectedDevbox.id} + + ); + lines.push( + + {' '} + Name: {selectedDevbox.name || '(none)'} + + ); + lines.push( + + {' '} + Status: {capitalize(selectedDevbox.status)} + + ); + lines.push( + + {' '} + Created: {new Date(selectedDevbox.create_time_ms).toLocaleString()} + + ); if (selectedDevbox.end_time_ms) { - lines.push( Ended: {new Date(selectedDevbox.end_time_ms).toLocaleString()}); + lines.push( + + {' '} + Ended: {new Date(selectedDevbox.end_time_ms).toLocaleString()} + + ); } lines.push( ); // Capabilities if (selectedDevbox.capabilities && selectedDevbox.capabilities.length > 0) { - lines.push(Capabilities); + lines.push( + + Capabilities + + ); selectedDevbox.capabilities.forEach((cap: string, idx: number) => { - lines.push( {figures.pointer} {cap}); + lines.push( + + {' '} + {figures.pointer} {cap} + + ); }); lines.push( ); } // Launch Parameters if (selectedDevbox.launch_parameters) { - lines.push(Launch Parameters); + lines.push( + + Launch Parameters + + ); const lp = selectedDevbox.launch_parameters; if (lp.resource_size_request) { - lines.push( Resource Size Request: {lp.resource_size_request}); + lines.push( + + {' '} + Resource Size Request: {lp.resource_size_request} + + ); } if (lp.architecture) { - lines.push( Architecture: {lp.architecture}); + lines.push( + + {' '} + Architecture: {lp.architecture} + + ); } if (lp.custom_cpu_cores) { - lines.push( CPU Cores: {lp.custom_cpu_cores}); + lines.push( + + {' '} + CPU Cores: {lp.custom_cpu_cores} + + ); } if (lp.custom_gb_memory) { - lines.push( Memory: {lp.custom_gb_memory}GB); + lines.push( + + {' '} + Memory: {lp.custom_gb_memory}GB + + ); } if (lp.custom_disk_size) { - lines.push( Disk Size: {lp.custom_disk_size}GB); + lines.push( + + {' '} + Disk Size: {lp.custom_disk_size}GB + + ); } if (lp.keep_alive_time_seconds) { - lines.push( Keep Alive: {lp.keep_alive_time_seconds}s ({Math.floor(lp.keep_alive_time_seconds / 60)}m)); + lines.push( + + {' '} + Keep Alive: {lp.keep_alive_time_seconds}s ({Math.floor(lp.keep_alive_time_seconds / 60)} + m) + + ); } if (lp.after_idle) { - lines.push( After Idle: {lp.after_idle.on_idle} after {lp.after_idle.idle_time_seconds}s); + lines.push( + + {' '} + After Idle: {lp.after_idle.on_idle} after {lp.after_idle.idle_time_seconds}s + + ); } if (lp.available_ports && lp.available_ports.length > 0) { - lines.push( Available Ports: {lp.available_ports.join(', ')}); + lines.push( + + {' '} + Available Ports: {lp.available_ports.join(', ')} + + ); } if (lp.launch_commands && lp.launch_commands.length > 0) { - lines.push( Launch Commands:); + lines.push( + + {' '} + Launch Commands: + + ); lp.launch_commands.forEach((cmd: string, idx: number) => { - lines.push( {figures.pointer} {cmd}); + lines.push( + + {' '} + {figures.pointer} {cmd} + + ); }); } if (lp.required_services && lp.required_services.length > 0) { - lines.push( Required Services: {lp.required_services.join(', ')}); + lines.push( + + {' '} + Required Services: {lp.required_services.join(', ')} + + ); } if (lp.user_parameters) { - lines.push( User Parameters:); + lines.push( + + {' '} + User Parameters: + + ); if (lp.user_parameters.username) { - lines.push( Username: {lp.user_parameters.username}); + lines.push( + + {' '} + Username: {lp.user_parameters.username} + + ); } if (lp.user_parameters.uid) { - lines.push( UID: {lp.user_parameters.uid}); + lines.push( + + {' '} + UID: {lp.user_parameters.uid} + + ); } } lines.push( ); @@ -249,62 +417,131 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Source if (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) { - lines.push(Source); + lines.push( + + Source + + ); if (selectedDevbox.blueprint_id) { - lines.push( Blueprint: {selectedDevbox.blueprint_id}); + lines.push( + + {' '} + Blueprint: {selectedDevbox.blueprint_id} + + ); } if (selectedDevbox.snapshot_id) { - lines.push( Snapshot: {selectedDevbox.snapshot_id}); + lines.push( + + {' '} + Snapshot: {selectedDevbox.snapshot_id} + + ); } lines.push( ); } // Initiator if (selectedDevbox.initiator_type) { - lines.push(Initiator); - lines.push( Type: {selectedDevbox.initiator_type}); + lines.push( + + Initiator + + ); + lines.push( + + {' '} + Type: {selectedDevbox.initiator_type} + + ); if (selectedDevbox.initiator_id) { - lines.push( ID: {selectedDevbox.initiator_id}); + lines.push( + + {' '} + ID: {selectedDevbox.initiator_id} + + ); } lines.push( ); } // Status Details if (selectedDevbox.failure_reason || selectedDevbox.shutdown_reason) { - lines.push(Status Details); + lines.push( + + Status Details + + ); if (selectedDevbox.failure_reason) { - lines.push( Failure Reason: {selectedDevbox.failure_reason}); + lines.push( + + {' '} + Failure Reason: {selectedDevbox.failure_reason} + + ); } if (selectedDevbox.shutdown_reason) { - lines.push( Shutdown Reason: {selectedDevbox.shutdown_reason}); + lines.push( + + {' '} + Shutdown Reason: {selectedDevbox.shutdown_reason} + + ); } lines.push( ); } // Metadata if (selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0) { - lines.push(Metadata); + lines.push( + + Metadata + + ); Object.entries(selectedDevbox.metadata).forEach(([key, value], idx) => { - lines.push( {key}: {value as string}); + lines.push( + + {' '} + {key}: {value as string} + + ); }); lines.push( ); } // State Transitions if (selectedDevbox.state_transitions && selectedDevbox.state_transitions.length > 0) { - lines.push(State History); + lines.push( + + State History + + ); selectedDevbox.state_transitions.forEach((transition: any, idx: number) => { const text = `${idx + 1}. ${capitalize(transition.status)}${transition.transition_time_ms ? ` at ${new Date(transition.transition_time_ms).toLocaleString()}` : ''}`; - lines.push( {text}); + lines.push( + + {' '} + {text} + + ); }); lines.push( ); } // Raw JSON (full) - lines.push(Raw JSON); + lines.push( + + Raw JSON + + ); const jsonLines = JSON.stringify(selectedDevbox, null, 2).split('\n'); jsonLines.forEach((line, idx) => { - lines.push( {line}); + lines.push( + + {' '} + {line} + + ); }); return lines; @@ -322,7 +559,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init }} breadcrumbItems={[ { label: 'Devboxes' }, - { label: selectedDevbox.name || selectedDevbox.id } + { label: selectedDevbox.name || selectedDevbox.id }, ]} initialOperation={selectedOp?.key} skipOperationsMenu={true} @@ -344,19 +581,21 @@ export const DevboxDetailPage: React.FC = ({ devbox: init return ( <> - -
+
- {selectedDevbox.id} + + {selectedDevbox.id} + @@ -369,9 +608,7 @@ export const DevboxDetailPage: React.FC = ({ devbox: init paddingX={2} paddingY={1} > - - {visibleLines} - + {visibleLines} {hasLess && ( {figures.arrowUp} More above @@ -387,7 +624,8 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {figures.arrowUp} - {figures.arrowDown} Scroll • [q or esc] Back to Details • Line {actualScroll + 1}-{Math.min(actualScroll + viewportHeight, detailLines.length)} of {detailLines.length} + {figures.arrowDown} Scroll • [q or esc] Back to Details • Line {actualScroll + 1}- + {Math.min(actualScroll + viewportHeight, detailLines.length)} of {detailLines.length} @@ -396,32 +634,51 @@ export const DevboxDetailPage: React.FC = ({ devbox: init // Main detail view const lp = selectedDevbox.launch_parameters; - const hasCapabilities = selectedDevbox.capabilities && selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; + const hasCapabilities = + selectedDevbox.capabilities && + selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').length > 0; return ( <> - + {/* Main info section */} - {selectedDevbox.name || selectedDevbox.id} + + {selectedDevbox.name || selectedDevbox.id} + - • {selectedDevbox.id} + + {' '} + • {selectedDevbox.id} + - {formattedCreateTime} - ({createTimeAgo}) + + {formattedCreateTime} + + + {' '} + ({createTimeAgo}) + {uptime !== null && selectedDevbox.status === 'running' && ( - Uptime: {uptime < 60 ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} + + Uptime: {uptime < 60 ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} + {lp?.keep_alive_time_seconds && ( - • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m + + {' '} + • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m + )} )} @@ -430,9 +687,15 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Resources + capabilities + source in one row */} {/* Resources */} - {(lp?.resource_size_request || lp?.custom_cpu_cores || lp?.custom_gb_memory || lp?.custom_disk_size || lp?.architecture) && ( + {(lp?.resource_size_request || + lp?.custom_cpu_cores || + lp?.custom_gb_memory || + lp?.custom_disk_size || + lp?.architecture) && ( - {figures.squareSmallFilled} Resources + + {figures.squareSmallFilled} Resources + {lp?.resource_size_request && `${lp.resource_size_request}`} {lp?.architecture && ` • ${lp.architecture}`} @@ -446,15 +709,21 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Capabilities */} {hasCapabilities && ( - {figures.tick} Capabilities - {selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').join(', ')} + + {figures.tick} Capabilities + + + {selectedDevbox.capabilities.filter((c: string) => c !== 'unknown').join(', ')} + )} {/* Source */} {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( - {figures.circleFilled} Source + + {figures.circleFilled} Source + {selectedDevbox.blueprint_id && `BP: ${selectedDevbox.blueprint_id}`} {selectedDevbox.snapshot_id && `Snap: ${selectedDevbox.snapshot_id}`} @@ -473,22 +742,35 @@ export const DevboxDetailPage: React.FC = ({ devbox: init {/* Failure */} {selectedDevbox.failure_reason && ( - {figures.cross} - {selectedDevbox.failure_reason} + + {figures.cross}{' '} + + + {selectedDevbox.failure_reason} + )} {/* Operations - inline display */} - {figures.play} Actions + + {figures.play} Actions + {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( - {isSelected ? figures.pointer : ' '} - {op.icon} {op.label} - [{op.shortcut}] + + {isSelected ? figures.pointer : ' '}{' '} + + + {op.icon} {op.label} + + + {' '} + [{op.shortcut}] + ); })} @@ -497,7 +779,8 @@ export const DevboxDetailPage: React.FC = ({ devbox: init - {figures.arrowUp}{figures.arrowDown} Navigate • [Enter] Execute • [i] Full Details • [o] Browser • [q] Back + {figures.arrowUp} + {figures.arrowDown} Navigate • [Enter] Execute • [i] Full Details • [o] Browser • [q] Back diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 69651e2c..b62421b3 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -25,29 +25,32 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { // Calculate terminal height once at mount and memoize const terminalHeight = React.useMemo(() => process.stdout.rows || 24, []); - const menuItems: MenuItem[] = React.useMemo(() => [ - { - key: 'devboxes', - label: 'Devboxes', - description: 'Manage cloud development environments', - icon: '◉', - color: colors.accent1, - }, - { - key: 'blueprints', - label: 'Blueprints', - description: 'Create and manage devbox templates', - icon: '▣', - color: colors.accent2, - }, - { - key: 'snapshots', - label: 'Snapshots', - description: 'Save and restore devbox states', - icon: '◈', - color: colors.accent3, - }, - ], []); + const menuItems: MenuItem[] = React.useMemo( + () => [ + { + key: 'devboxes', + label: 'Devboxes', + description: 'Manage cloud development environments', + icon: '◉', + color: colors.accent1, + }, + { + key: 'blueprints', + label: 'Blueprints', + description: 'Create and manage devbox templates', + icon: '▣', + color: colors.accent2, + }, + { + key: 'snapshots', + label: 'Snapshots', + description: 'Save and restore devbox states', + icon: '◈', + color: colors.accent3, + }, + ], + [] + ); useInput((input, key) => { if (key.upArrow && selectedIndex > 0) { @@ -114,7 +117,8 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { - {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + {figures.arrowUp} + {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit @@ -179,7 +183,8 @@ export const MainMenu: React.FC = React.memo(({ onSelect }) => { - {figures.arrowUp}{figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + {figures.arrowUp} + {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index 57af2d56..fcc63f36 100644 --- a/src/components/MetadataDisplay.tsx +++ b/src/components/MetadataDisplay.tsx @@ -13,7 +13,14 @@ interface MetadataDisplayProps { // Generate color for each key based on hash const getColorForKey = (key: string, index: number): string => { - const colorList = [colors.primary, colors.secondary, colors.warning, colors.info, colors.success, colors.error]; + const colorList = [ + colors.primary, + colors.secondary, + colors.warning, + colors.info, + colors.success, + colors.error, + ]; return colorList[index % colorList.length]; }; @@ -21,7 +28,7 @@ export const MetadataDisplay: React.FC = ({ metadata, title = 'Metadata', showBorder = false, - selectedKey + selectedKey, }) => { const entries = Object.entries(metadata); @@ -45,11 +52,11 @@ export const MetadataDisplay: React.FC = ({ return ( {isSelected && ( - {figures.pointer} + + {figures.pointer}{' '} + )} - - {`${key}: ${value}`} - + {`${key}: ${value}`} ); })} diff --git a/src/components/OperationsMenu.tsx b/src/components/OperationsMenu.tsx index a68e62be..2cc2063d 100644 --- a/src/components/OperationsMenu.tsx +++ b/src/components/OperationsMenu.tsx @@ -67,8 +67,7 @@ export const OperationsMenu: React.FC = ({ {figures.arrowUp} {figures.arrowDown} Navigate • [Enter] Select • - {additionalActions.map((action) => ` [${action.key}] ${action.label} •`)} - {' '}[q] Back + {additionalActions.map(action => ` [${action.key}] ${action.label} •`)} [q] Back diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx index 2746466f..56170aed 100644 --- a/src/components/ResourceListView.tsx +++ b/src/components/ResourceListView.tsx @@ -51,15 +51,15 @@ export interface ResourceListConfig { /** Status display configuration */ statusConfig?: { - success: string[]; // e.g., ['running', 'build_complete', 'ready'] - warning: string[]; // e.g., ['provisioning', 'building'] - error: string[]; // e.g., ['failure', 'build_failed'] + success: string[]; // e.g., ['running', 'build_complete', 'ready'] + warning: string[]; // e.g., ['provisioning', 'building'] + error: string[]; // e.g., ['failure', 'build_failed'] }; /** Search configuration */ searchConfig?: { enabled: boolean; - fields: (item: T) => string[]; // Fields to search in + fields: (item: T) => string[]; // Fields to search in placeholder?: string; }; @@ -91,7 +91,7 @@ export interface ResourceListConfig { /** Auto-refresh configuration */ autoRefresh?: { enabled: boolean; - interval?: number; // milliseconds + interval?: number; // milliseconds }; } @@ -120,23 +120,26 @@ export function ResourceListView({ config }: ResourceListViewProps) { const terminalHeight = stdout?.rows || 30; // Fetch resources - const fetchData = React.useCallback(async (isInitialLoad: boolean = false) => { - try { - if (isInitialLoad) { - setRefreshing(true); - } + const fetchData = React.useCallback( + async (isInitialLoad: boolean = false) => { + try { + if (isInitialLoad) { + setRefreshing(true); + } - const data = await config.fetchResources(); - setResources(data); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - if (isInitialLoad) { - setTimeout(() => setRefreshing(false), 300); + const data = await config.fetchResources(); + setResources(data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + if (isInitialLoad) { + setTimeout(() => setRefreshing(false), 300); + } } - } - }, [config.fetchResources]); + }, + [config.fetchResources] + ); // Initial load React.useEffect(() => { @@ -156,7 +159,7 @@ export function ResourceListView({ config }: ResourceListViewProps) { // Animate refresh icon React.useEffect(() => { const interval = setInterval(() => { - setRefreshIcon((prev) => (prev + 1) % 10); + setRefreshIcon(prev => (prev + 1) % 10); }, 80); return () => clearInterval(interval); }, []); @@ -272,9 +275,9 @@ export function ResourceListView({ config }: ResourceListViewProps) { if (loading) { return ( <> - + ); @@ -284,10 +287,13 @@ export function ResourceListView({ config }: ResourceListViewProps) { if (error) { return ( <> - - + + ); } @@ -296,9 +302,9 @@ export function ResourceListView({ config }: ResourceListViewProps) { if (!loading && !error && resources.length === 0) { return ( <> - + {config.emptyState && ( {figures.info} @@ -317,9 +323,9 @@ export function ResourceListView({ config }: ResourceListViewProps) { // List view with data return ( <> - + {/* Search bar */} {config.searchConfig?.enabled && ( @@ -337,14 +343,22 @@ export function ResourceListView({ config }: ResourceListViewProps) { setSelectedIndex(0); }} /> - [Esc to cancel] + + {' '} + [Esc to cancel] + )} {!searchMode && searchQuery && ( {figures.info} Searching for: - {searchQuery} - ({currentResources.length} results) [/ to edit, Esc to clear] + + {searchQuery} + + + {' '} + ({currentResources.length} results) [/ to edit, Esc to clear] + )} @@ -365,16 +379,25 @@ export function ResourceListView({ config }: ResourceListViewProps) { {figures.hamburger} {resources.length} - total + + {' '} + total + {totalPages > 1 && ( <> - + + {' '} + •{' '} + Page {currentPage + 1} of {totalPages} )} - + + {' '} + •{' '} + Showing {startIndex + 1}-{endIndex} of {filteredResources.length} @@ -384,9 +407,7 @@ export function ResourceListView({ config }: ResourceListViewProps) { {['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10]} ) : ( - - {figures.circleFilled} - + {figures.circleFilled} )} @@ -398,26 +419,33 @@ export function ResourceListView({ config }: ResourceListViewProps) { {totalPages > 1 && ( - {' '}• {figures.arrowLeft}{figures.arrowRight} Page + {' '} + • {figures.arrowLeft} + {figures.arrowRight} Page )} {config.onSelect && ( - {' '}• [Enter] Details + {' '} + • [Enter] Details )} {config.searchConfig?.enabled && ( - {' '}• [/] Search + {' '} + • [/] Search )} - {config.additionalShortcuts && config.additionalShortcuts.map(shortcut => ( - - {' '}• [{shortcut.key}] {shortcut.label} - - ))} + {config.additionalShortcuts && + config.additionalShortcuts.map(shortcut => ( + + {' '} + • [{shortcut.key}] {shortcut.label} + + ))} - {' '}• [Esc] Back + {' '} + • [Esc] Back diff --git a/src/components/SuccessMessage.tsx b/src/components/SuccessMessage.tsx index d133d6cf..6efd6391 100644 --- a/src/components/SuccessMessage.tsx +++ b/src/components/SuccessMessage.tsx @@ -8,10 +8,7 @@ interface SuccessMessageProps { details?: string; } -export const SuccessMessage: React.FC = ({ - message, - details, -}) => { +export const SuccessMessage: React.FC = ({ message, details }) => { return ( diff --git a/src/components/Table.example.tsx b/src/components/Table.example.tsx index 9317b186..9ae2a4c3 100644 --- a/src/components/Table.example.tsx +++ b/src/components/Table.example.tsx @@ -26,7 +26,7 @@ interface Blueprint { function BlueprintsTable({ blueprints, selectedIndex, - terminalWidth + terminalWidth, }: { blueprints: Blueprint[]; selectedIndex: number; @@ -46,35 +46,31 @@ function BlueprintsTable({ createComponentColumn( 'status', 'Status', - (bp) => , + bp => , { width: 2 } ), // ID column (responsive) - createTextColumn( - 'id', - 'ID', - (bp) => showFullId ? bp.id : bp.id.slice(0, 13), - { width: showFullId ? 25 : 15, color: colors.textDim, dimColor: true, bold: false } - ), + createTextColumn('id', 'ID', bp => (showFullId ? bp.id : bp.id.slice(0, 13)), { + width: showFullId ? 25 : 15, + color: colors.textDim, + dimColor: true, + bold: false, + }), // Name column - createTextColumn( - 'name', - 'Name', - (bp) => bp.name || '(unnamed)', - { width: 30 } - ), + createTextColumn('name', 'Name', bp => bp.name || '(unnamed)', { width: 30 }), // Description column (optional) - createTextColumn( - 'description', - 'Description', - (bp) => bp.description || '', - { width: 40, color: colors.textDim, dimColor: true, bold: false, visible: showDescription } - ), + createTextColumn('description', 'Description', bp => bp.description || '', { + width: 40, + color: colors.textDim, + dimColor: true, + bold: false, + visible: showDescription, + }), // Created time column createTextColumn( 'created', 'Created', - (bp) => new Date(bp.created_at).toLocaleDateString(), + bp => new Date(bp.created_at).toLocaleDateString(), { width: 15, color: colors.textDim, dimColor: true, bold: false } ), ]} @@ -103,7 +99,7 @@ interface Snapshot { function SnapshotsTable({ snapshots, selectedIndex, - terminalWidth + terminalWidth, }: { snapshots: Snapshot[]; selectedIndex: number; @@ -123,42 +119,37 @@ function SnapshotsTable({ createComponentColumn( 'status', 'Status', - (snap) => , + snap => , { width: 2 } ), // ID column (responsive) createTextColumn( 'id', 'ID', - (snap) => showFullId ? snap.id : snap.id.slice(0, 13), + snap => (showFullId ? snap.id : snap.id.slice(0, 13)), { width: showFullId ? 25 : 15, color: colors.textDim, dimColor: true, bold: false } ), // Name column - createTextColumn( - 'name', - 'Name', - (snap) => snap.name || '(unnamed)', - { width: 25 } - ), + createTextColumn('name', 'Name', snap => snap.name || '(unnamed)', { width: 25 }), // Devbox ID column - createTextColumn( - 'devbox', - 'Devbox', - (snap) => snap.devbox_id.slice(0, 13), - { width: 15, color: colors.primary, dimColor: true, bold: false } - ), + createTextColumn('devbox', 'Devbox', snap => snap.devbox_id.slice(0, 13), { + width: 15, + color: colors.primary, + dimColor: true, + bold: false, + }), // Size column (optional) createTextColumn( 'size', 'Size', - (snap) => snap.size_gb ? `${snap.size_gb.toFixed(1)}GB` : '', + snap => (snap.size_gb ? `${snap.size_gb.toFixed(1)}GB` : ''), { width: 10, color: colors.warning, dimColor: true, bold: false, visible: showSize } ), // Created time column createTextColumn( 'created', 'Created', - (snap) => new Date(snap.created_at).toLocaleDateString(), + snap => new Date(snap.created_at).toLocaleDateString(), { width: 15, color: colors.textDim, dimColor: true, bold: false } ), ]} @@ -193,12 +184,7 @@ function CustomComplexColumn() { keyExtractor={(item: CustomEntity) => item.id} selectedIndex={0} columns={[ - createTextColumn( - 'name', - 'Name', - (item) => item.name, - { width: 20 } - ), + createTextColumn('name', 'Name', item => item.name, { width: 20 }), // Custom component column with complex rendering createComponentColumn( 'tags', diff --git a/src/components/Table.tsx b/src/components/Table.tsx index dfe0920b..45521266 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -58,11 +58,18 @@ export function Table({ {/* Title bar (if provided) */} {title && ( - ╭─ {title} {'─'.repeat(Math.max(0, 10))}╮ + + ╭─ {title} {'─'.repeat(Math.max(0, 10))}╮ + )} - + {/* Header row */} {/* Space for selection pointer */} @@ -74,39 +81,39 @@ export function Table({ )} {/* Column headers */} - {visibleColumns.map((column) => ( + {visibleColumns.map(column => ( {column.label.slice(0, column.width).padEnd(column.width, ' ')} ))} - {/* Data rows */} - {data.map((row, index) => { - const isSelected = index === selectedIndex; - const rowKey = keyExtractor(row); + {/* Data rows */} + {data.map((row, index) => { + const isSelected = index === selectedIndex; + const rowKey = keyExtractor(row); - return ( - - {/* Selection pointer */} - {showSelection && ( - <> - - {isSelected ? figures.pointer : ' '} - - - - )} + return ( + + {/* Selection pointer */} + {showSelection && ( + <> + + {isSelected ? figures.pointer : ' '} + + + + )} - {/* Render each column */} - {visibleColumns.map((column, colIndex) => ( - - {column.render(row, index, isSelected)} - - ))} - - ); - })} + {/* Render each column */} + {visibleColumns.map((column, colIndex) => ( + + {column.render(row, index, isSelected)} + + ))} + + ); + })} ); @@ -150,7 +157,13 @@ export function createTextColumn( const padded = truncated.padEnd(width, ' '); return ( - + {padded} ); diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 5ab571d8..4d6fd722 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -74,67 +74,70 @@ export function useCursorPagination( const pageCache = React.useRef>(new Map()); const lastIdCache = React.useRef>(new Map()); - const fetchData = React.useCallback(async (isInitialLoad: boolean = false) => { - try { - if (isInitialLoad) { - setRefreshing(true); - } - setLoading(true); - - // Check cache first (skip on refresh) - if (!isInitialLoad && pageCache.current.has(currentPage)) { - setItems(pageCache.current.get(currentPage) || []); + const fetchData = React.useCallback( + async (isInitialLoad: boolean = false) => { + try { + if (isInitialLoad) { + setRefreshing(true); + } + setLoading(true); + + // Check cache first (skip on refresh) + if (!isInitialLoad && pageCache.current.has(currentPage)) { + setItems(pageCache.current.get(currentPage) || []); + setLoading(false); + return; + } + + const pageItems: T[] = []; + + // Get starting_at cursor from previous page's last ID + const startingAt = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined; + + // Build query params + const queryParams: any = { + limit: config.pageSize, + ...config.queryParams, + }; + if (startingAt) { + queryParams.starting_at = startingAt; + } + + // Fetch the page + const result = await config.fetchPage(queryParams); + + // Extract items (handle both array response and paginated response) + const fetchedItems = Array.isArray(result) ? result : result.items; + pageItems.push(...fetchedItems.slice(0, config.pageSize)); + + // Update pagination metadata + if (!Array.isArray(result)) { + setTotalCount(result.total_count || pageItems.length); + setHasMore(result.has_more || false); + } else { + setTotalCount(pageItems.length); + setHasMore(false); + } + + // Cache the page data and last ID + if (pageItems.length > 0) { + pageCache.current.set(currentPage, pageItems); + lastIdCache.current.set(currentPage, config.getItemId(pageItems[pageItems.length - 1])); + } + + // Update items for current page + setItems(pageItems); + } catch (err) { + setError(err as Error); + } finally { setLoading(false); - return; + if (isInitialLoad) { + setTimeout(() => setRefreshing(false), 300); + } } - - const pageItems: T[] = []; - - // Get starting_at cursor from previous page's last ID - const startingAt = currentPage > 0 ? lastIdCache.current.get(currentPage - 1) : undefined; - - // Build query params - const queryParams: any = { - limit: config.pageSize, - ...config.queryParams, - }; - if (startingAt) { - queryParams.starting_at = startingAt; - } - - // Fetch the page - const result = await config.fetchPage(queryParams); - - // Extract items (handle both array response and paginated response) - const fetchedItems = Array.isArray(result) ? result : result.items; - pageItems.push(...fetchedItems.slice(0, config.pageSize)); - - // Update pagination metadata - if (!Array.isArray(result)) { - setTotalCount(result.total_count || pageItems.length); - setHasMore(result.has_more || false); - } else { - setTotalCount(pageItems.length); - setHasMore(false); - } - - // Cache the page data and last ID - if (pageItems.length > 0) { - pageCache.current.set(currentPage, pageItems); - lastIdCache.current.set(currentPage, config.getItemId(pageItems[pageItems.length - 1])); - } - - // Update items for current page - setItems(pageItems); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - if (isInitialLoad) { - setTimeout(() => setRefreshing(false), 300); - } - } - }, [currentPage, config]); + }, + [currentPage, config] + ); // Initial load and page changes React.useEffect(() => { @@ -169,11 +172,14 @@ export function useCursorPagination( } }, [loading, currentPage]); - const goToPage = React.useCallback((page: number) => { - if (!loading && page >= 0) { - setCurrentPage(page); - } - }, [loading]); + const goToPage = React.useCallback( + (page: number) => { + if (!loading && page >= 0) { + setCurrentPage(page); + } + }, + [loading] + ); const refresh = React.useCallback(() => { pageCache.current.clear(); diff --git a/src/mcp/server-http.ts b/src/mcp/server-http.ts index f7b9361a..85e2597d 100644 --- a/src/mcp/server-http.ts +++ b/src/mcp/server-http.ts @@ -212,7 +212,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }); // Handle tool execution -server.setRequestHandler(CallToolRequestSchema, async (request) => { +server.setRequestHandler(CallToolRequestSchema, async request => { const { name, arguments: args } = request.params; try { @@ -256,7 +256,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (args.blueprint_id) createParams.blueprint_id = args.blueprint_id; if (args.snapshot_id) createParams.snapshot_id = args.snapshot_id; if (args.entrypoint) createParams.entrypoint = args.entrypoint; - if (args.environment_variables) createParams.environment_variables = args.environment_variables; + if (args.environment_variables) + createParams.environment_variables = args.environment_variables; if (args.resource_size) { createParams.launch_parameters = { resource_size_request: args.resource_size, @@ -428,7 +429,7 @@ async function main() { }); } -main().catch((error) => { +main().catch(error => { console.error('Fatal error in main():', error); process.exit(1); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a29f1af5..b34ca432 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -211,7 +211,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }); // Handle tool execution -server.setRequestHandler(CallToolRequestSchema, async (request) => { +server.setRequestHandler(CallToolRequestSchema, async request => { const { name, arguments: args } = request.params; try { @@ -255,7 +255,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (args.blueprint_id) createParams.blueprint_id = args.blueprint_id; if (args.snapshot_id) createParams.snapshot_id = args.snapshot_id; if (args.entrypoint) createParams.entrypoint = args.entrypoint; - if (args.environment_variables) createParams.environment_variables = args.environment_variables; + if (args.environment_variables) + createParams.environment_variables = args.environment_variables; if (args.resource_size) { createParams.launch_parameters = { resource_size_request: args.resource_size, @@ -405,7 +406,7 @@ async function main() { console.error('Runloop MCP server running on stdio'); } -main().catch((error) => { +main().catch(error => { console.error('Fatal error in main():', error); process.exit(1); }); diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index 1cee777b..293c6ae5 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -5,7 +5,12 @@ import { render } from 'ink'; import { getClient } from './client.js'; -import { shouldUseNonInteractiveOutput, outputList, outputResult, OutputOptions } from './output.js'; +import { + shouldUseNonInteractiveOutput, + outputList, + outputResult, + OutputOptions, +} from './output.js'; import YAML from 'yaml'; export class CommandExecutor { @@ -44,10 +49,7 @@ export class CommandExecutor { /** * Execute a create/action command with automatic format handling */ - async executeAction( - performAction: () => Promise, - renderUI: () => JSX.Element - ): Promise { + async executeAction(performAction: () => Promise, renderUI: () => JSX.Element): Promise { if (shouldUseNonInteractiveOutput(this.options)) { try { const result = await performAction(); diff --git a/src/utils/client.ts b/src/utils/client.ts index 81c8746d..df8dd99d 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -31,6 +31,6 @@ export function getClient(): Runloop { bearerToken: config.apiKey, baseURL, timeout: 10000, // 10 seconds instead of default 30 seconds - maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) + maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) }); } diff --git a/src/utils/output.ts b/src/utils/output.ts index 8fb9b76e..2ca7dafd 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -35,7 +35,7 @@ export function outputData(data: any, format: OutputFormat = 'json'): void { // Simple text output if (Array.isArray(data)) { // For lists of complex objects, just output IDs - data.forEach((item) => { + data.forEach(item => { if (typeof item === 'object' && item !== null && 'id' in item) { console.log(item.id); } else { @@ -73,11 +73,7 @@ function formatTextOutput(item: any): string { /** * Output a single result (for create, delete, etc) */ -export function outputResult( - result: any, - options: OutputOptions, - successMessage?: string -): void { +export function outputResult(result: any, options: OutputOptions, successMessage?: string): void { if (shouldUseNonInteractiveOutput(options)) { outputData(result, options.output as OutputFormat); return; @@ -92,10 +88,7 @@ export function outputResult( /** * Output a list of items (for list commands) */ -export function outputList( - items: any[], - options: OutputOptions -): void { +export function outputList(items: any[], options: OutputOptions): void { if (shouldUseNonInteractiveOutput(options)) { outputData(items, options.output as OutputFormat); } diff --git a/src/utils/sshSession.ts b/src/utils/sshSession.ts index 8c89aae1..3b0fb054 100644 --- a/src/utils/sshSession.ts +++ b/src/utils/sshSession.ts @@ -24,21 +24,29 @@ export async function runSSHSession(config: SSHSessionConfig): Promise Date: Tue, 7 Oct 2025 18:11:39 -0700 Subject: [PATCH 23/25] cp dines --- .prettierrc | 6 - src/cli.ts | 237 +++++++---- src/commands/auth.tsx | 34 +- src/commands/blueprint/list.tsx | 292 ++++++++----- src/commands/devbox/create.tsx | 26 +- src/commands/devbox/delete.tsx | 36 +- src/commands/devbox/exec.tsx | 31 +- src/commands/devbox/list.tsx | 474 +++++++++++++-------- src/commands/devbox/upload.tsx | 30 +- src/commands/mcp-http.ts | 26 +- src/commands/mcp-install.ts | 114 +++-- src/commands/mcp.ts | 22 +- src/commands/menu.tsx | 64 +-- src/commands/snapshot/create.tsx | 53 ++- src/commands/snapshot/delete.tsx | 31 +- src/commands/snapshot/list.tsx | 103 +++-- src/components/ActionsPopup.tsx | 44 +- src/components/Banner.tsx | 8 +- src/components/Breadcrumb.tsx | 25 +- src/components/DetailView.tsx | 14 +- src/components/DevboxActionsMenu.tsx | 609 ++++++++++++++++----------- src/components/DevboxCard.tsx | 27 +- src/components/DevboxCreatePage.tsx | 453 ++++++++++++-------- src/components/DevboxDetailPage.tsx | 389 +++++++++-------- src/components/ErrorMessage.tsx | 13 +- src/components/Header.tsx | 52 +-- src/components/MainMenu.tsx | 85 ++-- src/components/MetadataDisplay.tsx | 18 +- src/components/OperationsMenu.tsx | 24 +- src/components/ResourceListView.tsx | 132 +++--- src/components/Spinner.tsx | 12 +- src/components/StatusBadge.tsx | 119 ++++-- src/components/SuccessMessage.tsx | 15 +- src/components/Table.example.tsx | 154 ++++--- src/components/Table.tsx | 34 +- src/hooks/useCursorPagination.ts | 28 +- src/mcp/server-http.ts | 268 ++++++------ src/mcp/server.ts | 254 +++++------ src/utils/CommandExecutor.ts | 35 +- src/utils/client.ts | 14 +- src/utils/config.ts | 8 +- src/utils/interactiveCommand.ts | 4 +- src/utils/output.ts | 46 +- src/utils/sshSession.ts | 28 +- src/utils/theme.ts | 24 +- src/utils/url.ts | 8 +- 46 files changed, 2664 insertions(+), 1859 deletions(-) diff --git a/.prettierrc b/.prettierrc index d5076387..2c63c085 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,8 +1,2 @@ { - "semi": true, - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 100, - "arrowParens": "avoid" } diff --git a/src/cli.ts b/src/cli.ts index abc73be1..c51deb97 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,62 +1,78 @@ #!/usr/bin/env node -import { Command } from 'commander'; -import { createDevbox } from './commands/devbox/create.js'; -import { listDevboxes } from './commands/devbox/list.js'; -import { deleteDevbox } from './commands/devbox/delete.js'; -import { execCommand } from './commands/devbox/exec.js'; -import { uploadFile } from './commands/devbox/upload.js'; -import { getConfig } from './utils/config.js'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; +import { Command } from "commander"; +import { createDevbox } from "./commands/devbox/create.js"; +import { listDevboxes } from "./commands/devbox/list.js"; +import { deleteDevbox } from "./commands/devbox/delete.js"; +import { execCommand } from "./commands/devbox/exec.js"; +import { uploadFile } from "./commands/devbox/upload.js"; +import { getConfig } from "./utils/config.js"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; // Get version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')); +const packageJson = JSON.parse( + readFileSync(join(__dirname, "../package.json"), "utf8"), +); export const VERSION = packageJson.version; // Global Ctrl+C handler to ensure it always exits -process.on('SIGINT', () => { +process.on("SIGINT", () => { // Force exit immediately, clearing alternate screen buffer - process.stdout.write('\x1b[?1049l'); - process.stdout.write('\n'); + process.stdout.write("\x1b[?1049l"); + process.stdout.write("\n"); process.exit(130); // Standard exit code for SIGINT }); const program = new Command(); -program.name('rln').description('Beautiful CLI for Runloop devbox management').version(VERSION); +program + .name("rln") + .description("Beautiful CLI for Runloop devbox management") + .version(VERSION); program - .command('auth') - .description('Configure API authentication') + .command("auth") + .description("Configure API authentication") .action(async () => { - const { default: auth } = await import('./commands/auth.js'); + const { default: auth } = await import("./commands/auth.js"); auth(); }); // Devbox commands -const devbox = program.command('devbox').description('Manage devboxes').alias('d'); +const devbox = program + .command("devbox") + .description("Manage devboxes") + .alias("d"); devbox - .command('create') - .description('Create a new devbox') - .option('-n, --name ', 'Devbox name') - .option('-t, --template