@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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/cli.js +110 -0
  4. package/dist/commands/auth.js +27 -0
  5. package/dist/commands/blueprint/list.js +373 -0
  6. package/dist/commands/create.js +42 -0
  7. package/dist/commands/delete.js +34 -0
  8. package/dist/commands/devbox/create.js +45 -0
  9. package/dist/commands/devbox/delete.js +37 -0
  10. package/dist/commands/devbox/exec.js +35 -0
  11. package/dist/commands/devbox/list.js +302 -0
  12. package/dist/commands/devbox/upload.js +40 -0
  13. package/dist/commands/exec.js +35 -0
  14. package/dist/commands/list.js +59 -0
  15. package/dist/commands/snapshot/create.js +40 -0
  16. package/dist/commands/snapshot/delete.js +37 -0
  17. package/dist/commands/snapshot/list.js +122 -0
  18. package/dist/commands/upload.js +40 -0
  19. package/dist/components/Banner.js +11 -0
  20. package/dist/components/Breadcrumb.js +9 -0
  21. package/dist/components/DetailView.js +29 -0
  22. package/dist/components/DevboxCard.js +24 -0
  23. package/dist/components/DevboxCreatePage.js +370 -0
  24. package/dist/components/DevboxDetailPage.js +621 -0
  25. package/dist/components/ErrorMessage.js +6 -0
  26. package/dist/components/Header.js +6 -0
  27. package/dist/components/MetadataDisplay.js +24 -0
  28. package/dist/components/OperationsMenu.js +19 -0
  29. package/dist/components/Spinner.js +6 -0
  30. package/dist/components/StatusBadge.js +41 -0
  31. package/dist/components/SuccessMessage.js +6 -0
  32. package/dist/components/Table.example.js +55 -0
  33. package/dist/components/Table.js +54 -0
  34. package/dist/utils/CommandExecutor.js +115 -0
  35. package/dist/utils/client.js +13 -0
  36. package/dist/utils/config.js +17 -0
  37. package/dist/utils/output.js +115 -0
  38. package/package.json +69 -0
@@ -0,0 +1,302 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text, useInput, useApp, useStdout } from 'ink';
4
+ import figures from 'figures';
5
+ import { getClient } from '../../utils/client.js';
6
+ import { SpinnerComponent } from '../../components/Spinner.js';
7
+ import { ErrorMessage } from '../../components/ErrorMessage.js';
8
+ import { getStatusDisplay } from '../../components/StatusBadge.js';
9
+ import { Breadcrumb } from '../../components/Breadcrumb.js';
10
+ import { Table, createTextColumn } from '../../components/Table.js';
11
+ import { createExecutor } from '../../utils/CommandExecutor.js';
12
+ import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
13
+ import { DevboxCreatePage } from '../../components/DevboxCreatePage.js';
14
+ // Format time ago in a succinct way
15
+ const formatTimeAgo = (timestamp) => {
16
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
17
+ if (seconds < 60)
18
+ return `${seconds}s ago`;
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60)
21
+ return `${minutes}m ago`;
22
+ const hours = Math.floor(minutes / 60);
23
+ if (hours < 24)
24
+ return `${hours}h ago`;
25
+ const days = Math.floor(hours / 24);
26
+ if (days < 30)
27
+ return `${days}d ago`;
28
+ const months = Math.floor(days / 30);
29
+ if (months < 12)
30
+ return `${months}mo ago`;
31
+ const years = Math.floor(months / 12);
32
+ return `${years}y ago`;
33
+ };
34
+ const MAX_FETCH = 100;
35
+ const DEFAULT_PAGE_SIZE = 10;
36
+ const ListDevboxesUI = ({ status }) => {
37
+ const { exit } = useApp();
38
+ const { stdout } = useStdout();
39
+ const [loading, setLoading] = React.useState(true);
40
+ const [devboxes, setDevboxes] = React.useState([]);
41
+ const [error, setError] = React.useState(null);
42
+ const [currentPage, setCurrentPage] = React.useState(0);
43
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
44
+ const [showDetails, setShowDetails] = React.useState(false);
45
+ const [showCreate, setShowCreate] = React.useState(false);
46
+ const [refreshing, setRefreshing] = React.useState(false);
47
+ const [refreshIcon, setRefreshIcon] = React.useState(0);
48
+ // Calculate responsive dimensions
49
+ const terminalWidth = stdout?.columns || 120;
50
+ const terminalHeight = stdout?.rows || 30;
51
+ // Calculate dynamic page size based on terminal height
52
+ // Account for: Banner (3-4 lines) + Breadcrumb (1) + Header (1) + Stats (2) + Help text (2) + Margins (2) + Header row (1) = ~12 lines
53
+ const PAGE_SIZE = Math.max(5, terminalHeight - 12);
54
+ const fixedWidth = 4; // pointer + spaces
55
+ const statusIconWidth = 2;
56
+ const statusTextWidth = 10;
57
+ const timeWidth = 20;
58
+ const capabilitiesWidth = 18;
59
+ const tagWidth = 6;
60
+ // ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format)
61
+ const idWidth = 26;
62
+ // Responsive layout based on terminal width
63
+ const showCapabilities = terminalWidth >= 120;
64
+ const showTags = terminalWidth >= 110;
65
+ // Name width is flexible and uses remaining space
66
+ let nameWidth = 15;
67
+ if (terminalWidth >= 120) {
68
+ const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - capabilitiesWidth - tagWidth - 12;
69
+ nameWidth = Math.max(15, remainingWidth);
70
+ }
71
+ else if (terminalWidth >= 110) {
72
+ const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - tagWidth - 10;
73
+ nameWidth = Math.max(12, remainingWidth);
74
+ }
75
+ else {
76
+ const remainingWidth = terminalWidth - fixedWidth - statusIconWidth - idWidth - statusTextWidth - timeWidth - 10;
77
+ nameWidth = Math.max(8, remainingWidth);
78
+ }
79
+ React.useEffect(() => {
80
+ const list = async (isInitialLoad = false) => {
81
+ try {
82
+ // Only show refreshing indicator on initial load
83
+ if (isInitialLoad) {
84
+ setRefreshing(true);
85
+ }
86
+ const client = getClient();
87
+ const allDevboxes = [];
88
+ let count = 0;
89
+ for await (const devbox of client.devboxes.list()) {
90
+ if (!status || devbox.status === status) {
91
+ allDevboxes.push(devbox);
92
+ }
93
+ count++;
94
+ if (count >= MAX_FETCH) {
95
+ break;
96
+ }
97
+ }
98
+ // Only update if data actually changed
99
+ setDevboxes((prev) => {
100
+ const hasChanged = JSON.stringify(prev) !== JSON.stringify(allDevboxes);
101
+ return hasChanged ? allDevboxes : prev;
102
+ });
103
+ }
104
+ catch (err) {
105
+ setError(err);
106
+ }
107
+ finally {
108
+ setLoading(false);
109
+ // Show refresh indicator briefly
110
+ if (isInitialLoad) {
111
+ setTimeout(() => setRefreshing(false), 300);
112
+ }
113
+ }
114
+ };
115
+ list(true);
116
+ // Poll every 3 seconds (increased from 2), but only when in list view
117
+ const interval = setInterval(() => {
118
+ if (!showDetails && !showCreate) {
119
+ list(false);
120
+ }
121
+ }, 3000);
122
+ return () => clearInterval(interval);
123
+ }, [showDetails, showCreate]);
124
+ // Animate refresh icon only when in list view
125
+ React.useEffect(() => {
126
+ if (showDetails || showCreate) {
127
+ return; // Don't animate when not in list view
128
+ }
129
+ const interval = setInterval(() => {
130
+ setRefreshIcon((prev) => (prev + 1) % 10);
131
+ }, 80);
132
+ return () => clearInterval(interval);
133
+ }, [showDetails, showCreate]);
134
+ useInput((input, key) => {
135
+ const pageDevboxes = currentDevboxes.length;
136
+ // Skip input handling when in details view - let DevboxDetailPage handle it
137
+ if (showDetails) {
138
+ return;
139
+ }
140
+ // Skip input handling when in create view - let DevboxCreatePage handle it
141
+ if (showCreate) {
142
+ return;
143
+ }
144
+ // Handle list view
145
+ if (key.upArrow && selectedIndex > 0) {
146
+ setSelectedIndex(selectedIndex - 1);
147
+ }
148
+ else if (key.downArrow && selectedIndex < pageDevboxes - 1) {
149
+ setSelectedIndex(selectedIndex + 1);
150
+ }
151
+ else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) {
152
+ setCurrentPage(currentPage + 1);
153
+ setSelectedIndex(0);
154
+ }
155
+ else if ((input === 'p' || key.leftArrow) && currentPage > 0) {
156
+ setCurrentPage(currentPage - 1);
157
+ setSelectedIndex(0);
158
+ }
159
+ else if (key.return) {
160
+ console.clear();
161
+ setShowDetails(true);
162
+ }
163
+ else if (input === 'c') {
164
+ console.clear();
165
+ setShowCreate(true);
166
+ }
167
+ else if (input === 'o' && selectedDevbox) {
168
+ // Open in browser
169
+ const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`;
170
+ const openBrowser = async () => {
171
+ const { exec } = await import('child_process');
172
+ const platform = process.platform;
173
+ let openCommand;
174
+ if (platform === 'darwin') {
175
+ openCommand = `open "${url}"`;
176
+ }
177
+ else if (platform === 'win32') {
178
+ openCommand = `start "${url}"`;
179
+ }
180
+ else {
181
+ openCommand = `xdg-open "${url}"`;
182
+ }
183
+ exec(openCommand);
184
+ };
185
+ openBrowser();
186
+ }
187
+ else if (input === 'q') {
188
+ process.exit(0);
189
+ }
190
+ });
191
+ const running = devboxes.filter((d) => d.status === 'running').length;
192
+ const stopped = devboxes.filter((d) => ['stopped', 'suspended'].includes(d.status)).length;
193
+ const totalPages = Math.ceil(devboxes.length / PAGE_SIZE);
194
+ const startIndex = currentPage * PAGE_SIZE;
195
+ const endIndex = Math.min(startIndex + PAGE_SIZE, devboxes.length);
196
+ const currentDevboxes = devboxes.slice(startIndex, endIndex);
197
+ const selectedDevbox = currentDevboxes[selectedIndex];
198
+ // Create view
199
+ if (showCreate) {
200
+ return (_jsx(DevboxCreatePage, { onBack: () => {
201
+ setShowCreate(false);
202
+ }, onCreate: (devbox) => {
203
+ // Refresh the list after creation
204
+ setShowCreate(false);
205
+ // The list will auto-refresh via the polling effect
206
+ } }));
207
+ }
208
+ // Details view
209
+ if (showDetails && selectedDevbox) {
210
+ return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
211
+ }
212
+ // List view
213
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
214
+ { label: 'Devboxes', active: true }
215
+ ] }), loading && _jsx(SpinnerComponent, { message: "Loading..." }), !loading && !error && devboxes.length === 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No devboxes found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln devbox create" })] })), !loading && !error && devboxes.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${devboxes.length}]`, columns: [
216
+ {
217
+ key: 'statusIcon',
218
+ label: '',
219
+ width: statusIconWidth,
220
+ render: (devbox, index, isSelected) => {
221
+ const statusDisplay = getStatusDisplay(devbox.status);
222
+ const status = devbox.status;
223
+ let color = 'gray';
224
+ if (status === 'running')
225
+ color = 'green';
226
+ else if (status === 'stopped' || status === 'suspended')
227
+ color = 'gray';
228
+ else if (status === 'starting' || status === 'stopping')
229
+ color = 'yellow';
230
+ else if (status === 'failed')
231
+ color = 'red';
232
+ const padded = statusDisplay.icon.padEnd(statusIconWidth, ' ');
233
+ return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
234
+ }
235
+ },
236
+ createTextColumn('id', 'ID', (devbox) => devbox.id, { width: idWidth, color: 'gray', dimColor: true, bold: false }),
237
+ {
238
+ key: 'statusText',
239
+ label: 'Status',
240
+ width: statusTextWidth,
241
+ render: (devbox, index, isSelected) => {
242
+ const statusDisplay = getStatusDisplay(devbox.status);
243
+ const status = devbox.status;
244
+ let color = 'gray';
245
+ if (status === 'running')
246
+ color = 'green';
247
+ else if (status === 'stopped' || status === 'suspended')
248
+ color = 'gray';
249
+ else if (status === 'starting' || status === 'stopping')
250
+ color = 'yellow';
251
+ else if (status === 'failed')
252
+ color = 'red';
253
+ const truncated = statusDisplay.text.slice(0, statusTextWidth);
254
+ const padded = truncated.padEnd(statusTextWidth, ' ');
255
+ return (_jsx(Text, { color: isSelected ? 'white' : color, bold: true, inverse: isSelected, children: padded }));
256
+ }
257
+ },
258
+ createTextColumn('name', 'Name', (devbox) => devbox.name || '', { width: nameWidth }),
259
+ createTextColumn('capabilities', 'Capabilities', (devbox) => {
260
+ const hasCapabilities = devbox.capabilities && devbox.capabilities.filter((c) => c !== 'unknown').length > 0;
261
+ return hasCapabilities
262
+ ? `[${devbox.capabilities
263
+ .filter((c) => c !== 'unknown')
264
+ .map((c) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c)
265
+ .join(',')}]`
266
+ : '';
267
+ }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
268
+ createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
269
+ createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
270
+ ] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", devboxes.length, devboxes.length >= MAX_FETCH && '+'] }), _jsx(Text, { color: "gray", dimColor: true, children: " total" }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", devboxes.length] }), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: "cyan", children: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10] })) : (_jsx(Text, { color: "green", children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 [Enter] Operations \u2022 [c] Create \u2022 [o] Browser \u2022 [q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
271
+ };
272
+ export async function listDevboxes(options) {
273
+ const executor = createExecutor(options);
274
+ await executor.executeList(async () => {
275
+ const client = executor.getClient();
276
+ return executor.fetchFromIterator(client.devboxes.list(), {
277
+ filter: options.status ? (devbox) => devbox.status === options.status : undefined,
278
+ limit: DEFAULT_PAGE_SIZE,
279
+ });
280
+ }, () => _jsx(ListDevboxesUI, { status: options.status }), DEFAULT_PAGE_SIZE);
281
+ // Check if we need to spawn SSH after Ink exit
282
+ const sshCommand = global.__sshCommand;
283
+ if (sshCommand) {
284
+ delete global.__sshCommand;
285
+ // Import spawn
286
+ const { spawnSync } = await import('child_process');
287
+ // Clear and show connection message
288
+ console.clear();
289
+ console.log(`\nConnecting to devbox ${sshCommand.devboxName}...\n`);
290
+ // Spawn SSH in foreground
291
+ const result = spawnSync('ssh', [
292
+ '-i', sshCommand.keyPath,
293
+ '-o', `ProxyCommand=${sshCommand.proxyCommand}`,
294
+ '-o', 'StrictHostKeyChecking=no',
295
+ '-o', 'UserKnownHostsFile=/dev/null',
296
+ `${sshCommand.sshUser}@${sshCommand.url}`
297
+ ], {
298
+ stdio: 'inherit'
299
+ });
300
+ process.exit(result.status || 0);
301
+ }
302
+ }
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { createReadStream } from 'fs';
5
+ import { getClient } from '../../utils/client.js';
6
+ import { Header } from '../../components/Header.js';
7
+ import { SpinnerComponent } from '../../components/Spinner.js';
8
+ import { SuccessMessage } from '../../components/SuccessMessage.js';
9
+ import { ErrorMessage } from '../../components/ErrorMessage.js';
10
+ const UploadFileUI = ({ id, file, targetPath }) => {
11
+ const [loading, setLoading] = React.useState(true);
12
+ const [success, setSuccess] = React.useState(false);
13
+ const [error, setError] = React.useState(null);
14
+ React.useEffect(() => {
15
+ const upload = async () => {
16
+ try {
17
+ const client = getClient();
18
+ const fileStream = createReadStream(file);
19
+ const filename = file.split('/').pop() || 'uploaded-file';
20
+ await client.devboxes.uploadFile(id, {
21
+ path: targetPath || filename,
22
+ file: fileStream,
23
+ });
24
+ setSuccess(true);
25
+ }
26
+ catch (err) {
27
+ setError(err);
28
+ }
29
+ finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
+ upload();
34
+ }, []);
35
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Upload File", subtitle: `Uploading to devbox: ${id}` }), loading && _jsx(SpinnerComponent, { message: "Uploading file..." }), success && (_jsx(SuccessMessage, { message: "File uploaded successfully!", details: `File: ${file}${targetPath ? `\nTarget: ${targetPath}` : ''}` })), error && _jsx(ErrorMessage, { message: "Failed to upload file", error: error })] }));
36
+ };
37
+ export async function uploadFile(id, file, options) {
38
+ const { waitUntilExit } = render(_jsx(UploadFileUI, { id: id, file: file, targetPath: options.path }));
39
+ await waitUntilExit();
40
+ }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render, Box, Text } from 'ink';
4
+ import { getClient } from '../utils/client.js';
5
+ import { Header } from '../components/Header.js';
6
+ import { SpinnerComponent } from '../components/Spinner.js';
7
+ import { ErrorMessage } from '../components/ErrorMessage.js';
8
+ const ExecCommandUI = ({ id, command, }) => {
9
+ const [loading, setLoading] = React.useState(true);
10
+ const [output, setOutput] = React.useState('');
11
+ const [error, setError] = React.useState(null);
12
+ React.useEffect(() => {
13
+ const exec = async () => {
14
+ try {
15
+ const client = getClient();
16
+ const result = await client.devboxes.executeSync(id, {
17
+ command: command.join(' '),
18
+ });
19
+ setOutput(result.stdout || result.stderr || 'Command executed successfully');
20
+ }
21
+ catch (err) {
22
+ setError(err);
23
+ }
24
+ finally {
25
+ setLoading(false);
26
+ }
27
+ };
28
+ exec();
29
+ }, []);
30
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Execute Command", subtitle: `Running in devbox: ${id}` }), loading && _jsx(SpinnerComponent, { message: "Executing command..." }), !loading && !error && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Box, { borderStyle: "round", borderColor: "green", padding: 1, children: _jsx(Text, { children: output }) }) })), error && _jsx(ErrorMessage, { message: "Failed to execute command", error: error })] }));
31
+ };
32
+ export async function execCommand(id, command) {
33
+ const { waitUntilExit } = render(_jsx(ExecCommandUI, { id: id, command: command }));
34
+ await waitUntilExit();
35
+ }
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render, Box, Text, useInput } from 'ink';
4
+ import Gradient from 'ink-gradient';
5
+ import figures from 'figures';
6
+ import { getClient } from '../utils/client.js';
7
+ import { Header } from '../components/Header.js';
8
+ import { SpinnerComponent } from '../components/Spinner.js';
9
+ import { DevboxCard } from '../components/DevboxCard.js';
10
+ import { ErrorMessage } from '../components/ErrorMessage.js';
11
+ const PAGE_SIZE = 10;
12
+ const ListDevboxesUI = ({ status }) => {
13
+ const [loading, setLoading] = React.useState(true);
14
+ const [devboxes, setDevboxes] = React.useState([]);
15
+ const [error, setError] = React.useState(null);
16
+ const [currentPage, setCurrentPage] = React.useState(0);
17
+ React.useEffect(() => {
18
+ const list = async () => {
19
+ try {
20
+ const client = getClient();
21
+ const allDevboxes = [];
22
+ for await (const devbox of client.devboxes.list()) {
23
+ if (!status || devbox.status === status) {
24
+ allDevboxes.push(devbox);
25
+ }
26
+ }
27
+ setDevboxes(allDevboxes);
28
+ }
29
+ catch (err) {
30
+ setError(err);
31
+ }
32
+ finally {
33
+ setLoading(false);
34
+ }
35
+ };
36
+ list();
37
+ }, []);
38
+ useInput((input, key) => {
39
+ if (input === 'n' && currentPage < totalPages - 1) {
40
+ setCurrentPage(currentPage + 1);
41
+ }
42
+ else if (input === 'p' && currentPage > 0) {
43
+ setCurrentPage(currentPage - 1);
44
+ }
45
+ else if (input === 'q') {
46
+ process.exit(0);
47
+ }
48
+ });
49
+ const getStatusCount = (status) => devboxes.filter((d) => d.status === status).length;
50
+ const totalPages = Math.ceil(devboxes.length / PAGE_SIZE);
51
+ const startIndex = currentPage * PAGE_SIZE;
52
+ const endIndex = Math.min(startIndex + PAGE_SIZE, devboxes.length);
53
+ const currentDevboxes = devboxes.slice(startIndex, endIndex);
54
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Your Devboxes", subtitle: status ? `Filtering by status: ${status}` : 'Showing all devboxes' }), loading && _jsx(SpinnerComponent, { message: "Fetching your devboxes..." }), !loading && !error && devboxes.length === 0 && (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 2, marginY: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "yellow", bold: true, children: [figures.info, " No devboxes found"] }) }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: "gray", children: "Create your first devbox with: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln create" })] })] })), !loading && !error && devboxes.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 3, paddingY: 1, marginY: 1, flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { children: _jsx(Gradient, { name: "passion", children: _jsxs(Text, { bold: true, children: [figures.star, " Summary"] }) }) }), _jsx(Box, { children: _jsxs(Text, { color: "cyan", children: ["Page ", currentPage + 1, "/", totalPages, " (", startIndex + 1, "-", endIndex, " of", ' ', devboxes.length, ")"] }) })] }), _jsxs(Box, { marginTop: 1, gap: 3, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", bold: true, children: figures.tick }), _jsx(Text, { color: "gray", children: "Running:" }), _jsx(Text, { color: "green", bold: true, children: getStatusCount('running') })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: figures.ellipsis }), _jsx(Text, { color: "gray", children: "Provisioning:" }), _jsx(Text, { color: "yellow", bold: true, children: getStatusCount('provisioning') + getStatusCount('initializing') })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", bold: true, children: figures.circleDotted }), _jsx(Text, { color: "gray", children: "Stopped:" }), _jsx(Text, { color: "gray", bold: true, children: getStatusCount('stopped') + getStatusCount('suspended') })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: figures.hamburger }), _jsx(Text, { color: "gray", children: "Total:" }), _jsx(Text, { color: "cyan", bold: true, children: devboxes.length })] })] })] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: currentDevboxes.map((devbox, index) => (_jsx(DevboxCard, { id: devbox.id, name: devbox.name, status: devbox.status, createdAt: devbox.created_at, index: startIndex + index }, devbox.id))) }), totalPages > 1 && (_jsx(Box, { borderStyle: "round", borderColor: "blue", paddingX: 3, paddingY: 1, marginTop: 1, flexDirection: "column", children: _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 3, children: [currentPage > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "[p]" }), _jsx(Text, { color: "gray", children: " Previous" })] })), currentPage < totalPages - 1 && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "[n]" }), _jsx(Text, { color: "gray", children: " Next" })] })), _jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "[q]" }), _jsx(Text, { color: "gray", children: " Quit" })] })] }), _jsx(Box, { children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowRight, " Press a key to navigate"] }) })] }) }))] })), error && _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
55
+ };
56
+ export async function listDevboxes(options) {
57
+ const { waitUntilExit } = render(_jsx(ListDevboxesUI, { status: options.status }));
58
+ await waitUntilExit();
59
+ }
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render, Box, Text } from 'ink';
4
+ import Gradient from 'ink-gradient';
5
+ import figures from 'figures';
6
+ import { getClient } from '../../utils/client.js';
7
+ import { Header } from '../../components/Header.js';
8
+ import { Banner } from '../../components/Banner.js';
9
+ import { SpinnerComponent } from '../../components/Spinner.js';
10
+ import { SuccessMessage } from '../../components/SuccessMessage.js';
11
+ import { ErrorMessage } from '../../components/ErrorMessage.js';
12
+ const CreateSnapshotUI = ({ devboxId, name }) => {
13
+ const [loading, setLoading] = React.useState(true);
14
+ const [result, setResult] = React.useState(null);
15
+ const [error, setError] = React.useState(null);
16
+ React.useEffect(() => {
17
+ const create = async () => {
18
+ try {
19
+ const client = getClient();
20
+ const snapshot = await client.devboxes.snapshotDisk(devboxId, {
21
+ ...(name && { name }),
22
+ });
23
+ setResult(snapshot);
24
+ }
25
+ catch (err) {
26
+ setError(err);
27
+ }
28
+ finally {
29
+ setLoading(false);
30
+ }
31
+ };
32
+ create();
33
+ }, []);
34
+ return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), _jsx(Header, { title: "Create Snapshot", subtitle: "Taking a snapshot of your devbox..." }), loading && (_jsxs(_Fragment, { children: [_jsx(SpinnerComponent, { message: "Creating snapshot..." }), _jsxs(Box, { borderStyle: "round", borderColor: "blue", paddingX: 3, paddingY: 1, marginY: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "cyan", bold: true, children: [figures.info, " Configuration"] }) }), _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [figures.pointer, " Devbox ID: "] }), _jsx(Text, { color: "white", children: devboxId })] }), name && (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [figures.pointer, " Name: "] }), _jsx(Text, { color: "white", children: name })] }))] })] })] })), result && (_jsxs(_Fragment, { children: [_jsx(SuccessMessage, { message: "Snapshot created successfully!", details: `ID: ${result.id}\nName: ${result.name || '(unnamed)'}\nStatus: ${result.status}` }), _jsxs(Box, { borderStyle: "double", borderColor: "green", paddingX: 3, paddingY: 1, marginY: 1, flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Gradient, { name: "summer", children: _jsxs(Text, { bold: true, children: [figures.star, " Next Steps"] }) }) }), _jsxs(Box, { flexDirection: "column", gap: 1, marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [figures.tick, " View snapshots: "] }), _jsx(Text, { color: "cyan", children: "rln snapshot list" })] }), _jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [figures.tick, " Create devbox from snapshot: "] }), _jsxs(Text, { color: "cyan", children: ["rln devbox create -t ", result.id] })] })] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to create snapshot", error: error })] }));
35
+ };
36
+ export async function createSnapshot(devboxId, options) {
37
+ console.clear();
38
+ const { waitUntilExit } = render(_jsx(CreateSnapshotUI, { devboxId: devboxId, name: options.name }));
39
+ await waitUntilExit();
40
+ }
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { getClient } from '../../utils/client.js';
4
+ import { Header } from '../../components/Header.js';
5
+ import { SpinnerComponent } from '../../components/Spinner.js';
6
+ import { SuccessMessage } from '../../components/SuccessMessage.js';
7
+ import { ErrorMessage } from '../../components/ErrorMessage.js';
8
+ import { createExecutor } from '../../utils/CommandExecutor.js';
9
+ const DeleteSnapshotUI = ({ id }) => {
10
+ const [loading, setLoading] = React.useState(true);
11
+ const [success, setSuccess] = React.useState(false);
12
+ const [error, setError] = React.useState(null);
13
+ React.useEffect(() => {
14
+ const deleteSnapshot = async () => {
15
+ try {
16
+ const client = getClient();
17
+ await client.devboxes.diskSnapshots.delete(id);
18
+ setSuccess(true);
19
+ }
20
+ catch (err) {
21
+ setError(err);
22
+ }
23
+ finally {
24
+ setLoading(false);
25
+ }
26
+ };
27
+ deleteSnapshot();
28
+ }, []);
29
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Delete Snapshot", subtitle: `Deleting snapshot: ${id}` }), loading && _jsx(SpinnerComponent, { message: "Deleting snapshot..." }), success && (_jsx(SuccessMessage, { message: "Snapshot deleted successfully!", details: `ID: ${id}` })), error && _jsx(ErrorMessage, { message: "Failed to delete snapshot", error: error })] }));
30
+ };
31
+ export async function deleteSnapshot(id, options = {}) {
32
+ const executor = createExecutor(options);
33
+ await executor.executeDelete(async () => {
34
+ const client = executor.getClient();
35
+ await client.devboxes.diskSnapshots.delete(id);
36
+ }, id, () => _jsx(DeleteSnapshotUI, { id: id }));
37
+ }
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import figures from 'figures';
5
+ import { getClient } from '../../utils/client.js';
6
+ import { Header } from '../../components/Header.js';
7
+ import { Banner } from '../../components/Banner.js';
8
+ import { SpinnerComponent } from '../../components/Spinner.js';
9
+ import { ErrorMessage } from '../../components/ErrorMessage.js';
10
+ import { StatusBadge } from '../../components/StatusBadge.js';
11
+ import { Breadcrumb } from '../../components/Breadcrumb.js';
12
+ import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js';
13
+ import { createExecutor } from '../../utils/CommandExecutor.js';
14
+ const PAGE_SIZE = 10;
15
+ const MAX_FETCH = 100;
16
+ // Format time ago
17
+ const formatTimeAgo = (timestamp) => {
18
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
19
+ if (seconds < 60)
20
+ return `${seconds}s ago`;
21
+ const minutes = Math.floor(seconds / 60);
22
+ if (minutes < 60)
23
+ return `${minutes}m ago`;
24
+ const hours = Math.floor(minutes / 60);
25
+ if (hours < 24)
26
+ return `${hours}h ago`;
27
+ const days = Math.floor(hours / 24);
28
+ if (days < 30)
29
+ return `${days}d ago`;
30
+ const months = Math.floor(days / 30);
31
+ if (months < 12)
32
+ return `${months}mo ago`;
33
+ const years = Math.floor(months / 12);
34
+ return `${years}y ago`;
35
+ };
36
+ const ListSnapshotsUI = ({ devboxId }) => {
37
+ const { stdout } = useStdout();
38
+ const [loading, setLoading] = React.useState(true);
39
+ const [snapshots, setSnapshots] = React.useState([]);
40
+ const [error, setError] = React.useState(null);
41
+ const [currentPage, setCurrentPage] = React.useState(0);
42
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
43
+ // Calculate responsive column widths
44
+ const terminalWidth = stdout?.columns || 120;
45
+ const showDevboxId = terminalWidth >= 100 && !devboxId; // Hide devbox column if filtering by devbox
46
+ const showFullId = terminalWidth >= 80;
47
+ const idWidth = 25;
48
+ const nameWidth = terminalWidth >= 120 ? 30 : 25;
49
+ const devboxWidth = 15;
50
+ const timeWidth = 20;
51
+ React.useEffect(() => {
52
+ const list = async () => {
53
+ try {
54
+ const client = getClient();
55
+ const allSnapshots = [];
56
+ let count = 0;
57
+ const params = devboxId ? { devbox_id: devboxId } : {};
58
+ for await (const snapshot of client.devboxes.listDiskSnapshots(params)) {
59
+ allSnapshots.push(snapshot);
60
+ count++;
61
+ if (count >= MAX_FETCH) {
62
+ break;
63
+ }
64
+ }
65
+ setSnapshots(allSnapshots);
66
+ }
67
+ catch (err) {
68
+ setError(err);
69
+ }
70
+ finally {
71
+ setLoading(false);
72
+ }
73
+ };
74
+ list();
75
+ }, [devboxId]);
76
+ useInput((input, key) => {
77
+ const pageSnapshots = currentSnapshots.length;
78
+ if (key.upArrow && selectedIndex > 0) {
79
+ setSelectedIndex(selectedIndex - 1);
80
+ }
81
+ else if (key.downArrow && selectedIndex < pageSnapshots - 1) {
82
+ setSelectedIndex(selectedIndex + 1);
83
+ }
84
+ else if ((input === 'n' || key.rightArrow) && currentPage < totalPages - 1) {
85
+ setCurrentPage(currentPage + 1);
86
+ setSelectedIndex(0);
87
+ }
88
+ else if ((input === 'p' || key.leftArrow) && currentPage > 0) {
89
+ setCurrentPage(currentPage - 1);
90
+ setSelectedIndex(0);
91
+ }
92
+ else if (input === 'q') {
93
+ process.exit(0);
94
+ }
95
+ });
96
+ const totalPages = Math.ceil(snapshots.length / PAGE_SIZE);
97
+ const startIndex = currentPage * PAGE_SIZE;
98
+ const endIndex = Math.min(startIndex + PAGE_SIZE, snapshots.length);
99
+ const currentSnapshots = snapshots.slice(startIndex, endIndex);
100
+ const ready = snapshots.filter((s) => s.status === 'ready').length;
101
+ const pending = snapshots.filter((s) => s.status !== 'ready').length;
102
+ return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), _jsx(Breadcrumb, { items: [
103
+ { label: 'Snapshots', active: !devboxId },
104
+ ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
105
+ ] }), _jsx(Header, { title: "Snapshots", subtitle: devboxId ? `Filtering by devbox: ${devboxId}` : undefined }), loading && _jsx(SpinnerComponent, { message: "Loading snapshots..." }), !loading && !error && snapshots.length === 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No snapshots found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln snapshot create <devbox-id>" })] })), !loading && !error && snapshots.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "green", children: [figures.tick, " ", ready] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: [figures.ellipsis, " ", pending] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "cyan", children: [figures.hamburger, " ", snapshots.length, snapshots.length >= MAX_FETCH && '+'] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, "/", totalPages] })] }))] }), _jsx(Table, { data: currentSnapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, columns: [
106
+ createComponentColumn('status', 'Status', (snapshot) => _jsx(StatusBadge, { status: snapshot.status, showText: false }), { width: 2 }),
107
+ createTextColumn('id', 'ID', (snapshot) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), { width: showFullId ? idWidth : 15, color: 'gray', dimColor: true, bold: false }),
108
+ createTextColumn('name', 'Name', (snapshot) => snapshot.name || '(unnamed)', { width: nameWidth }),
109
+ createTextColumn('devbox', 'Devbox', (snapshot) => snapshot.devbox_id || '', { width: devboxWidth, color: 'cyan', dimColor: true, bold: false, visible: showDevboxId }),
110
+ createTextColumn('created', 'Created', (snapshot) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
111
+ ] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', figures.arrowLeft, figures.arrowRight, " Page \u2022"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list snapshots", error: error })] }));
112
+ };
113
+ export async function listSnapshots(options) {
114
+ const executor = createExecutor(options);
115
+ await executor.executeList(async () => {
116
+ const client = executor.getClient();
117
+ const params = options.devbox ? { devbox_id: options.devbox } : {};
118
+ return executor.fetchFromIterator(client.devboxes.listDiskSnapshots(params), {
119
+ limit: PAGE_SIZE,
120
+ });
121
+ }, () => _jsx(ListSnapshotsUI, { devboxId: options.devbox }), PAGE_SIZE);
122
+ }