@runloop/rl-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli.js +110 -0
- package/dist/commands/auth.js +27 -0
- package/dist/commands/blueprint/list.js +373 -0
- package/dist/commands/create.js +42 -0
- package/dist/commands/delete.js +34 -0
- package/dist/commands/devbox/create.js +45 -0
- package/dist/commands/devbox/delete.js +37 -0
- package/dist/commands/devbox/exec.js +35 -0
- package/dist/commands/devbox/list.js +302 -0
- package/dist/commands/devbox/upload.js +40 -0
- package/dist/commands/exec.js +35 -0
- package/dist/commands/list.js +59 -0
- package/dist/commands/snapshot/create.js +40 -0
- package/dist/commands/snapshot/delete.js +37 -0
- package/dist/commands/snapshot/list.js +122 -0
- package/dist/commands/upload.js +40 -0
- package/dist/components/Banner.js +11 -0
- package/dist/components/Breadcrumb.js +9 -0
- package/dist/components/DetailView.js +29 -0
- package/dist/components/DevboxCard.js +24 -0
- package/dist/components/DevboxCreatePage.js +370 -0
- package/dist/components/DevboxDetailPage.js +621 -0
- package/dist/components/ErrorMessage.js +6 -0
- package/dist/components/Header.js +6 -0
- package/dist/components/MetadataDisplay.js +24 -0
- package/dist/components/OperationsMenu.js +19 -0
- package/dist/components/Spinner.js +6 -0
- package/dist/components/StatusBadge.js +41 -0
- package/dist/components/SuccessMessage.js +6 -0
- package/dist/components/Table.example.js +55 -0
- package/dist/components/Table.js +54 -0
- package/dist/utils/CommandExecutor.js +115 -0
- package/dist/utils/client.js +13 -0
- package/dist/utils/config.js +17 -0
- package/dist/utils/output.js +115 -0
- package/package.json +69 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Badge } from '@inkjs/ui';
|
|
4
|
+
import figures from 'figures';
|
|
5
|
+
// Generate color for each key based on hash
|
|
6
|
+
const getColorForKey = (key, index) => {
|
|
7
|
+
const colors = ['cyan', 'magenta', 'yellow', 'blue', 'green', 'red'];
|
|
8
|
+
return colors[index % colors.length];
|
|
9
|
+
};
|
|
10
|
+
export const MetadataDisplay = ({ metadata, title = 'Metadata', showBorder = false, selectedKey }) => {
|
|
11
|
+
const entries = Object.entries(metadata);
|
|
12
|
+
if (entries.length === 0) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const content = (_jsxs(Box, { flexDirection: "row", alignItems: "center", flexWrap: "wrap", children: [title && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "#0a4d3a", bold: true, children: [figures.info, " ", title] }), _jsx(Text, { children: " " })] })), entries.map(([key, value], index) => {
|
|
16
|
+
const color = getColorForKey(key, index);
|
|
17
|
+
const isSelected = selectedKey === key;
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "row", alignItems: "center", children: [isSelected && (_jsxs(Text, { color: "cyan", bold: true, children: [figures.pointer, " "] })), _jsx(Badge, { color: isSelected ? 'cyan' : color, children: `${key}: ${value}` })] }, key));
|
|
19
|
+
})] }));
|
|
20
|
+
if (showBorder) {
|
|
21
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: "#0a4d3a", paddingX: 2, paddingY: 1, flexDirection: "column", children: content }));
|
|
22
|
+
}
|
|
23
|
+
return content;
|
|
24
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import figures from 'figures';
|
|
4
|
+
/**
|
|
5
|
+
* Reusable operations menu component for detail pages
|
|
6
|
+
* Displays a list of available operations with keyboard navigation
|
|
7
|
+
*/
|
|
8
|
+
export const OperationsMenu = ({ operations, selectedIndex, onNavigate, onSelect, onBack, additionalActions = [], }) => {
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
|
|
10
|
+
const isSelected = index === selectedIndex;
|
|
11
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: [isSelected ? figures.pointer : ' ', ' '] }), _jsxs(Text, { color: isSelected ? op.color : 'gray', bold: isSelected, children: [op.icon, " ", op.label] })] }, op.key));
|
|
12
|
+
}) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022", additionalActions.map((action) => ` [${action.key}] ${action.label} •`), ' ', "[q] Back"] }) })] }));
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Helper to filter operations based on conditions
|
|
16
|
+
*/
|
|
17
|
+
export function filterOperations(allOperations, condition) {
|
|
18
|
+
return allOperations.filter(condition);
|
|
19
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
export const SpinnerComponent = ({ message }) => {
|
|
5
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] }));
|
|
6
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import figures from 'figures';
|
|
4
|
+
export const getStatusDisplay = (status) => {
|
|
5
|
+
if (!status) {
|
|
6
|
+
return { icon: figures.questionMarkPrefix, color: 'gray', text: 'UNKNOWN' };
|
|
7
|
+
}
|
|
8
|
+
switch (status) {
|
|
9
|
+
case 'running':
|
|
10
|
+
return { icon: figures.circleFilled, color: 'green', text: 'RUNNING' };
|
|
11
|
+
case 'provisioning':
|
|
12
|
+
return { icon: figures.hamburger, color: 'yellow', text: 'PROVISIONING' };
|
|
13
|
+
case 'initializing':
|
|
14
|
+
return { icon: figures.ellipsis, color: 'cyan', text: 'INITIALIZING' };
|
|
15
|
+
case 'suspended':
|
|
16
|
+
return { icon: figures.circleDotted, color: 'yellow', text: 'SUSPENDED' };
|
|
17
|
+
case 'failure':
|
|
18
|
+
return { icon: figures.cross, color: 'red', text: 'FAILED' };
|
|
19
|
+
case 'shutdown':
|
|
20
|
+
return { icon: figures.circle, color: 'gray', text: 'SHUTDOWN' };
|
|
21
|
+
case 'resuming':
|
|
22
|
+
return { icon: figures.ellipsis, color: 'cyan', text: 'RESUMING' };
|
|
23
|
+
case 'suspending':
|
|
24
|
+
return { icon: figures.ellipsis, color: 'yellow', text: 'SUSPENDING' };
|
|
25
|
+
case 'ready':
|
|
26
|
+
return { icon: figures.tick, color: 'green', text: 'READY' };
|
|
27
|
+
case 'build_complete':
|
|
28
|
+
case 'building_complete':
|
|
29
|
+
return { icon: figures.tick, color: 'green', text: 'COMPLETE' };
|
|
30
|
+
case 'building':
|
|
31
|
+
return { icon: figures.ellipsis, color: 'yellow', text: 'BUILDING' };
|
|
32
|
+
case 'build_failed':
|
|
33
|
+
return { icon: figures.cross, color: 'red', text: 'FAILED' };
|
|
34
|
+
default:
|
|
35
|
+
return { icon: figures.questionMarkPrefix, color: 'gray', text: status.toUpperCase() };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
export const StatusBadge = ({ status, showText = true }) => {
|
|
39
|
+
const statusDisplay = getStatusDisplay(status);
|
|
40
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusDisplay.color, children: statusDisplay.icon }), showText && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: statusDisplay.color, children: statusDisplay.text })] }))] }));
|
|
41
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import figures from 'figures';
|
|
4
|
+
export const SuccessMessage = ({ message, details, }) => {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [figures.tick, " ", message] }) }), details && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: details.split('\n').map((line, i) => (_jsx(Text, { color: "gray", dimColor: true, children: line }, i))) }))] }));
|
|
6
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Table, createTextColumn, createComponentColumn } from './Table.js';
|
|
4
|
+
import { StatusBadge } from './StatusBadge.js';
|
|
5
|
+
import figures from 'figures';
|
|
6
|
+
function BlueprintsTable({ blueprints, selectedIndex, terminalWidth }) {
|
|
7
|
+
// Responsive column widths
|
|
8
|
+
const showDescription = terminalWidth >= 120;
|
|
9
|
+
const showFullId = terminalWidth >= 80;
|
|
10
|
+
return (_jsx(Table, { data: blueprints, keyExtractor: (bp) => bp.id, selectedIndex: selectedIndex, columns: [
|
|
11
|
+
// Status badge column
|
|
12
|
+
createComponentColumn('status', 'Status', (bp) => _jsx(StatusBadge, { status: bp.status, showText: false }), { width: 2 }),
|
|
13
|
+
// ID column (responsive)
|
|
14
|
+
createTextColumn('id', 'ID', (bp) => showFullId ? bp.id : bp.id.slice(0, 13), { width: showFullId ? 25 : 15, color: 'gray', dimColor: true, bold: false }),
|
|
15
|
+
// Name column
|
|
16
|
+
createTextColumn('name', 'Name', (bp) => bp.name || '(unnamed)', { width: 30 }),
|
|
17
|
+
// Description column (optional)
|
|
18
|
+
createTextColumn('description', 'Description', (bp) => bp.description || '', { width: 40, color: 'gray', dimColor: true, bold: false, visible: showDescription }),
|
|
19
|
+
// Created time column
|
|
20
|
+
createTextColumn('created', 'Created', (bp) => new Date(bp.created_at).toLocaleDateString(), { width: 15, color: 'gray', dimColor: true, bold: false }),
|
|
21
|
+
], emptyState: _jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [figures.info, " No blueprints found"] }) }) }));
|
|
22
|
+
}
|
|
23
|
+
function SnapshotsTable({ snapshots, selectedIndex, terminalWidth }) {
|
|
24
|
+
// Responsive column widths
|
|
25
|
+
const showSize = terminalWidth >= 100;
|
|
26
|
+
const showFullId = terminalWidth >= 80;
|
|
27
|
+
return (_jsx(Table, { data: snapshots, keyExtractor: (snap) => snap.id, selectedIndex: selectedIndex, columns: [
|
|
28
|
+
// Status badge column
|
|
29
|
+
createComponentColumn('status', 'Status', (snap) => _jsx(StatusBadge, { status: snap.status, showText: false }), { width: 2 }),
|
|
30
|
+
// ID column (responsive)
|
|
31
|
+
createTextColumn('id', 'ID', (snap) => showFullId ? snap.id : snap.id.slice(0, 13), { width: showFullId ? 25 : 15, color: 'gray', dimColor: true, bold: false }),
|
|
32
|
+
// Name column
|
|
33
|
+
createTextColumn('name', 'Name', (snap) => snap.name || '(unnamed)', { width: 25 }),
|
|
34
|
+
// Devbox ID column
|
|
35
|
+
createTextColumn('devbox', 'Devbox', (snap) => snap.devbox_id.slice(0, 13), { width: 15, color: 'cyan', dimColor: true, bold: false }),
|
|
36
|
+
// Size column (optional)
|
|
37
|
+
createTextColumn('size', 'Size', (snap) => snap.size_gb ? `${snap.size_gb.toFixed(1)}GB` : '', { width: 10, color: 'yellow', dimColor: true, bold: false, visible: showSize }),
|
|
38
|
+
// Created time column
|
|
39
|
+
createTextColumn('created', 'Created', (snap) => new Date(snap.created_at).toLocaleDateString(), { width: 15, color: 'gray', dimColor: true, bold: false }),
|
|
40
|
+
], emptyState: _jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [figures.info, " No snapshots found"] }) }) }));
|
|
41
|
+
}
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// EXAMPLE 3: Custom Column with Complex Rendering
|
|
44
|
+
// ============================================================================
|
|
45
|
+
function CustomComplexColumn() {
|
|
46
|
+
const data = [
|
|
47
|
+
{ id: '1', name: 'Item 1', tags: ['tag1', 'tag2'] },
|
|
48
|
+
{ id: '2', name: 'Item 2', tags: ['tag3'] },
|
|
49
|
+
];
|
|
50
|
+
return (_jsx(Table, { data: data, keyExtractor: (item) => item.id, selectedIndex: 0, columns: [
|
|
51
|
+
createTextColumn('name', 'Name', (item) => item.name, { width: 20 }),
|
|
52
|
+
// Custom component column with complex rendering
|
|
53
|
+
createComponentColumn('tags', 'Tags', (item, index, isSelected) => (_jsx(Box, { width: 30, children: _jsx(Text, { color: isSelected ? 'cyan' : 'blue', dimColor: true, children: item.tags.map(tag => `[${tag}]`).join(' ') }) })), { width: 30 }),
|
|
54
|
+
] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import figures from 'figures';
|
|
5
|
+
/**
|
|
6
|
+
* Reusable table component for displaying lists of data with optional selection
|
|
7
|
+
* Designed to be responsive and work across devboxes, blueprints, and snapshots
|
|
8
|
+
*/
|
|
9
|
+
export function Table({ data, columns, selectedIndex = -1, showSelection = true, emptyState, keyExtractor, title, }) {
|
|
10
|
+
if (data.length === 0 && emptyState) {
|
|
11
|
+
return _jsx(_Fragment, { children: emptyState });
|
|
12
|
+
}
|
|
13
|
+
// Filter visible columns
|
|
14
|
+
const visibleColumns = columns.filter(col => col.visible !== false);
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsx(Box, { paddingX: 1, marginBottom: 0, children: _jsxs(Text, { color: "cyan", bold: true, children: ["\u256D\u2500 ", title, " ", '─'.repeat(Math.max(0, 10)), "\u256E"] }) })), _jsxs(Box, { flexDirection: "column", borderStyle: title ? 'single' : 'round', borderColor: "gray", paddingX: 1, children: [_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })), visibleColumns.map((column) => (_jsx(Text, { bold: true, dimColor: true, children: column.label.slice(0, column.width).padEnd(column.width, ' ') }, `header-${column.key}`)))] }), data.map((row, index) => {
|
|
16
|
+
const isSelected = index === selectedIndex;
|
|
17
|
+
const rowKey = keyExtractor(row);
|
|
18
|
+
return (_jsxs(Box, { children: [showSelection && (_jsxs(_Fragment, { children: [_jsx(Text, { color: isSelected ? 'cyan' : 'gray', children: isSelected ? figures.pointer : ' ' }), _jsx(Text, { children: " " })] })), visibleColumns.map((column, colIndex) => (_jsx(React.Fragment, { children: column.render(row, index, isSelected) }, `${rowKey}-${column.key}`)))] }, rowKey));
|
|
19
|
+
})] })] }));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Helper function to create a simple text column
|
|
23
|
+
*/
|
|
24
|
+
export function createTextColumn(key, label, getValue, options) {
|
|
25
|
+
return {
|
|
26
|
+
key,
|
|
27
|
+
label,
|
|
28
|
+
width: options?.width || 20,
|
|
29
|
+
visible: options?.visible,
|
|
30
|
+
render: (row, index, isSelected) => {
|
|
31
|
+
const value = getValue(row);
|
|
32
|
+
const width = options?.width || 20;
|
|
33
|
+
const color = options?.color || (isSelected ? 'white' : 'white');
|
|
34
|
+
const bold = options?.bold !== undefined ? options.bold : isSelected;
|
|
35
|
+
const dimColor = options?.dimColor || false;
|
|
36
|
+
// Pad the value to fill the full width
|
|
37
|
+
const truncated = value.slice(0, width);
|
|
38
|
+
const padded = truncated.padEnd(width, ' ');
|
|
39
|
+
return (_jsx(Text, { color: isSelected ? 'white' : color, bold: bold, dimColor: !isSelected && dimColor, inverse: isSelected, children: padded }));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Helper function to create a component column (for badges, icons, etc.)
|
|
45
|
+
*/
|
|
46
|
+
export function createComponentColumn(key, label, renderComponent, options) {
|
|
47
|
+
return {
|
|
48
|
+
key,
|
|
49
|
+
label,
|
|
50
|
+
width: options?.width || 20,
|
|
51
|
+
visible: options?.visible,
|
|
52
|
+
render: renderComponent,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared class for executing commands with different output formats
|
|
3
|
+
* Reduces code duplication across all command files
|
|
4
|
+
*/
|
|
5
|
+
import { render } from 'ink';
|
|
6
|
+
import { getClient } from './client.js';
|
|
7
|
+
import { shouldUseNonInteractiveOutput, outputList, outputResult } from './output.js';
|
|
8
|
+
import YAML from 'yaml';
|
|
9
|
+
export class CommandExecutor {
|
|
10
|
+
options;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Execute a list command with automatic format handling
|
|
16
|
+
*/
|
|
17
|
+
async executeList(fetchData, renderUI, limit = 10) {
|
|
18
|
+
if (shouldUseNonInteractiveOutput(this.options)) {
|
|
19
|
+
try {
|
|
20
|
+
const items = await fetchData();
|
|
21
|
+
// Limit results for non-interactive mode
|
|
22
|
+
const limitedItems = items.slice(0, limit);
|
|
23
|
+
outputList(limitedItems, this.options);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
this.handleError(err);
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Interactive mode
|
|
31
|
+
console.clear();
|
|
32
|
+
const { waitUntilExit } = render(renderUI());
|
|
33
|
+
await waitUntilExit();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Execute a create/action command with automatic format handling
|
|
37
|
+
*/
|
|
38
|
+
async executeAction(performAction, renderUI) {
|
|
39
|
+
if (shouldUseNonInteractiveOutput(this.options)) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await performAction();
|
|
42
|
+
outputResult(result, this.options);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.handleError(err);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Interactive mode
|
|
50
|
+
console.clear();
|
|
51
|
+
const { waitUntilExit } = render(renderUI());
|
|
52
|
+
await waitUntilExit();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Execute a delete command with automatic format handling
|
|
56
|
+
*/
|
|
57
|
+
async executeDelete(performDelete, id, renderUI) {
|
|
58
|
+
if (shouldUseNonInteractiveOutput(this.options)) {
|
|
59
|
+
try {
|
|
60
|
+
await performDelete();
|
|
61
|
+
outputResult({ id, status: 'deleted' }, this.options);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
this.handleError(err);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Interactive mode
|
|
69
|
+
const { waitUntilExit } = render(renderUI());
|
|
70
|
+
await waitUntilExit();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Fetch items from an async iterator with optional filtering and limits
|
|
74
|
+
*/
|
|
75
|
+
async fetchFromIterator(iterator, options = {}) {
|
|
76
|
+
const { filter, limit = 100 } = options;
|
|
77
|
+
const items = [];
|
|
78
|
+
let count = 0;
|
|
79
|
+
for await (const item of iterator) {
|
|
80
|
+
if (filter && !filter(item)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
items.push(item);
|
|
84
|
+
count++;
|
|
85
|
+
if (count >= limit) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return items;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Handle errors consistently across all commands
|
|
93
|
+
*/
|
|
94
|
+
handleError(error) {
|
|
95
|
+
if (this.options.output === 'yaml') {
|
|
96
|
+
console.error(YAML.stringify({ error: error.message }));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.error(JSON.stringify({ error: error.message }, null, 2));
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get the client instance
|
|
105
|
+
*/
|
|
106
|
+
getClient() {
|
|
107
|
+
return getClient();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Factory function to create a CommandExecutor
|
|
112
|
+
*/
|
|
113
|
+
export function createExecutor(options = {}) {
|
|
114
|
+
return new CommandExecutor(options);
|
|
115
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Runloop from '@runloop/api-client';
|
|
2
|
+
import { getConfig } from './config.js';
|
|
3
|
+
export function getClient() {
|
|
4
|
+
const config = getConfig();
|
|
5
|
+
if (!config.apiKey) {
|
|
6
|
+
throw new Error('API key not configured. Run: rln auth');
|
|
7
|
+
}
|
|
8
|
+
return new Runloop({
|
|
9
|
+
bearerToken: config.apiKey,
|
|
10
|
+
timeout: 10000, // 10 seconds instead of default 30 seconds
|
|
11
|
+
maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
const config = new Conf({
|
|
3
|
+
projectName: 'runloop-cli',
|
|
4
|
+
});
|
|
5
|
+
export function getConfig() {
|
|
6
|
+
// Check environment variable first, then fall back to stored config
|
|
7
|
+
const apiKey = process.env.RUNLOOP_API_KEY || config.get('apiKey');
|
|
8
|
+
return {
|
|
9
|
+
apiKey,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function setApiKey(apiKey) {
|
|
13
|
+
config.set('apiKey', apiKey);
|
|
14
|
+
}
|
|
15
|
+
export function clearConfig() {
|
|
16
|
+
config.clear();
|
|
17
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility for handling different output formats across CLI commands
|
|
3
|
+
*/
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
/**
|
|
6
|
+
* Check if the command should use non-interactive output
|
|
7
|
+
*/
|
|
8
|
+
export function shouldUseNonInteractiveOutput(options) {
|
|
9
|
+
return !!options.output;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Output data in the specified format
|
|
13
|
+
*/
|
|
14
|
+
export function outputData(data, format = 'json') {
|
|
15
|
+
if (format === 'json') {
|
|
16
|
+
console.log(JSON.stringify(data, null, 2));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (format === 'yaml') {
|
|
20
|
+
console.log(YAML.stringify(data));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (format === 'text') {
|
|
24
|
+
// Simple text output
|
|
25
|
+
if (Array.isArray(data)) {
|
|
26
|
+
// For lists of complex objects, just output IDs
|
|
27
|
+
data.forEach((item) => {
|
|
28
|
+
if (typeof item === 'object' && item !== null && 'id' in item) {
|
|
29
|
+
console.log(item.id);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(formatTextOutput(item));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(formatTextOutput(data));
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.error(`Unknown output format: ${format}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format a single item as text output
|
|
46
|
+
*/
|
|
47
|
+
function formatTextOutput(item) {
|
|
48
|
+
if (typeof item === 'string') {
|
|
49
|
+
return item;
|
|
50
|
+
}
|
|
51
|
+
// For objects, create a simple key: value format
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const [key, value] of Object.entries(item)) {
|
|
54
|
+
if (value !== null && value !== undefined) {
|
|
55
|
+
lines.push(`${key}: ${value}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Output a single result (for create, delete, etc)
|
|
62
|
+
*/
|
|
63
|
+
export function outputResult(result, options, successMessage) {
|
|
64
|
+
if (shouldUseNonInteractiveOutput(options)) {
|
|
65
|
+
outputData(result, options.output);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Interactive mode - print success message
|
|
69
|
+
if (successMessage) {
|
|
70
|
+
console.log(successMessage);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Output a list of items (for list commands)
|
|
75
|
+
*/
|
|
76
|
+
export function outputList(items, options) {
|
|
77
|
+
if (shouldUseNonInteractiveOutput(options)) {
|
|
78
|
+
outputData(items, options.output);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Handle errors in both interactive and non-interactive modes
|
|
83
|
+
*/
|
|
84
|
+
export function outputError(error, options) {
|
|
85
|
+
if (shouldUseNonInteractiveOutput(options)) {
|
|
86
|
+
if (options.output === 'json') {
|
|
87
|
+
console.error(JSON.stringify({ error: error.message }, null, 2));
|
|
88
|
+
}
|
|
89
|
+
else if (options.output === 'yaml') {
|
|
90
|
+
console.error(YAML.stringify({ error: error.message }));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.error(`Error: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
// Let interactive UI handle the error
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate output format option
|
|
102
|
+
*/
|
|
103
|
+
export function validateOutputFormat(format) {
|
|
104
|
+
if (!format || format === 'text') {
|
|
105
|
+
return 'text';
|
|
106
|
+
}
|
|
107
|
+
if (format === 'json') {
|
|
108
|
+
return 'json';
|
|
109
|
+
}
|
|
110
|
+
if (format === 'yaml') {
|
|
111
|
+
return 'yaml';
|
|
112
|
+
}
|
|
113
|
+
console.error(`Unknown output format: ${format}. Valid options: text, json, yaml`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runloop/rl-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Beautiful CLI for Runloop devbox management",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rln": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"start": "node dist/cli.js",
|
|
13
|
+
"prepublishOnly": "npm run build",
|
|
14
|
+
"version:patch": "npm version patch",
|
|
15
|
+
"version:minor": "npm version minor",
|
|
16
|
+
"version:major": "npm version major",
|
|
17
|
+
"release": "npm run build && npm publish"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"runloop",
|
|
21
|
+
"cli",
|
|
22
|
+
"devbox",
|
|
23
|
+
"cloud",
|
|
24
|
+
"development"
|
|
25
|
+
],
|
|
26
|
+
"author": "Runloop",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/runloop/rl-cli-node.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/runloop/rl-cli-node/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/runloop/rl-cli-node#readme",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@inkjs/ui": "^2.0.0",
|
|
49
|
+
"@runloop/api-client": "^0.55.0",
|
|
50
|
+
"chalk": "^5.3.0",
|
|
51
|
+
"commander": "^12.1.0",
|
|
52
|
+
"conf": "^13.0.1",
|
|
53
|
+
"figures": "^6.1.0",
|
|
54
|
+
"gradient-string": "^2.0.2",
|
|
55
|
+
"ink": "^5.0.1",
|
|
56
|
+
"ink-big-text": "^2.0.0",
|
|
57
|
+
"ink-gradient": "^3.0.0",
|
|
58
|
+
"ink-link": "^4.1.0",
|
|
59
|
+
"ink-spinner": "^5.0.0",
|
|
60
|
+
"ink-text-input": "^6.0.0",
|
|
61
|
+
"react": "^18.3.1",
|
|
62
|
+
"yaml": "^2.8.1"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^22.7.9",
|
|
66
|
+
"@types/react": "^18.3.11",
|
|
67
|
+
"typescript": "^5.6.3"
|
|
68
|
+
}
|
|
69
|
+
}
|