gitarsenal-cli 1.9.106 → 1.9.108

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.
@@ -0,0 +1,848 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { render, useKeyboard, useTerminalDimensions } from '@opentui/react';
4
+ import { useState, useEffect } from 'react';
5
+ import { spawn } from 'child_process';
6
+ import { join } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname } from 'path';
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ // Helper functions for user credentials
16
+ const getUserConfigPath = () => {
17
+ const userConfigDir = join(os.homedir(), '.gitarsenal');
18
+ const userConfigPath = join(userConfigDir, 'user-config.json');
19
+ return { userConfigDir, userConfigPath };
20
+ };
21
+
22
+ const loadUserCredentials = () => {
23
+ const { userConfigPath } = getUserConfigPath();
24
+ if (fs.existsSync(userConfigPath)) {
25
+ try {
26
+ const config = JSON.parse(fs.readFileSync(userConfigPath, 'utf8'));
27
+ if (config.userId && config.userName && config.userEmail && !config.userEmail.includes('@example.com')) {
28
+ return config;
29
+ }
30
+ } catch (error) {
31
+ console.error('Could not read user config:', error);
32
+ }
33
+ }
34
+ return null;
35
+ };
36
+
37
+ const saveUserCredentials = (userId, userName, userEmail) => {
38
+ const { userConfigDir, userConfigPath } = getUserConfigPath();
39
+ try {
40
+ if (!fs.existsSync(userConfigDir)) {
41
+ fs.mkdirSync(userConfigDir, { recursive: true });
42
+ }
43
+ const config = {
44
+ userId,
45
+ userName,
46
+ userEmail,
47
+ savedAt: new Date().toISOString()
48
+ };
49
+ fs.writeFileSync(userConfigPath, JSON.stringify(config, null, 2));
50
+ return true;
51
+ } catch (error) {
52
+ console.error('Could not save credentials:', error);
53
+ return false;
54
+ }
55
+ };
56
+
57
+ const menuItems = [
58
+ 'Create New Sandbox',
59
+ 'View Running Sandboxes',
60
+ 'API Keys Management',
61
+ 'Settings',
62
+ 'Help & Examples',
63
+ 'Exit'
64
+ ];
65
+
66
+ const gpuOptions = [
67
+ { name: 'T4 (16GB VRAM)', value: 'T4' },
68
+ { name: 'L4 (24GB VRAM)', value: 'L4' },
69
+ { name: 'A10G (24GB VRAM)', value: 'A10G' },
70
+ { name: 'A100-40 (40GB VRAM)', value: 'A100-40GB' },
71
+ { name: 'A100-80 (80GB VRAM)', value: 'A100-80GB' },
72
+ { name: 'L40S (48GB VRAM)', value: 'L40S' },
73
+ { name: 'H100 (80GB VRAM)', value: 'H100' },
74
+ { name: 'H200 (141GB VRAM)', value: 'H200' },
75
+ { name: 'B200 (141GB VRAM)', value: 'B200' }
76
+ ];
77
+
78
+ const gpuCountOptions = [1, 2, 3, 4, 6, 8];
79
+
80
+ const providerOptions = [
81
+ { name: 'Modal (GPU support, persistent volumes)', value: 'modal' },
82
+ { name: 'E2B (Faster startup, no GPU)', value: 'e2b' }
83
+ ];
84
+
85
+ const Banner = () => {
86
+ return (
87
+ <box style={{ flexDirection: 'column', alignItems: 'center', marginBottom: 1 }}>
88
+ <box borderStyle="single" borderColor="green" paddingX={2} paddingY={0}>
89
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
90
+ <text bold fg="green"> ██████╗ ██╗████████╗ █████╗ ██████╗ ███████╗███████╗███╗ ██╗ █████╗ ██╗ </text>
91
+ <text bold fg="green">██╔════╝ ██║╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝████╗ ██║██╔══██╗██║ </text>
92
+ <text bold fg="green">██║ ███╗██║ ██║ ███████║██████╔╝███████╗█████╗ ██╔██╗ ██║███████║██║ </text>
93
+ <text bold fg="green">██║ ██║██║ ██║ ██╔══██║██╔══██╗╚════██║██╔══╝ ██║╚██╗██║██╔══██║██║ </text>
94
+ <text bold fg="green">╚██████╔╝██║ ██║ ██║ ██║██║ ██║███████║███████╗██║ ╚████║██║ ██║███████╗</text>
95
+ <text bold fg="green"> ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝</text>
96
+ <text bold>GPU-Accelerated Development Environments</text>
97
+ </box>
98
+ </box>
99
+ </box>
100
+ );
101
+ };
102
+
103
+ const Menu = ({ selectedIndex }) => {
104
+ return (
105
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
106
+ {menuItems.map((item, index) => (
107
+ <box key={index} marginY={0} marginBottom={0}>
108
+ {index === selectedIndex ? (
109
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={36} style={{ justifyContent: 'center' }}>
110
+ <text bold fg="cyan">{item}</text>
111
+ </box>
112
+ ) : (
113
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={36} style={{ justifyContent: 'center' }}>
114
+ <text dimColor>{item}</text>
115
+ </box>
116
+ )}
117
+ </box>
118
+ ))}
119
+ <box marginTop={1}>
120
+ <text dimColor fg="gray">Use ↑↓ arrows to navigate, Enter to select, Ctrl+C to exit</text>
121
+ </box>
122
+ </box>
123
+ );
124
+ };
125
+
126
+ const RepoInput = ({ value, onInput, onSubmit }) => {
127
+ return (
128
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
129
+ <box borderStyle="single" padding={1} marginBottom={1}>
130
+ <text bold>Create New Sandbox</text>
131
+ </box>
132
+ <box marginBottom={1}>
133
+ <text bold>Enter GitHub repository URL:</text>
134
+ </box>
135
+ <input
136
+ value={value}
137
+ onInput={onInput}
138
+ onSubmit={onSubmit}
139
+ placeholder="https://github.com/pytorch/examples"
140
+ focused
141
+ width={60}
142
+ />
143
+ <box marginTop={2} style={{ flexDirection: 'column', alignItems: 'center' }}>
144
+ <text dimColor fg="gray">Examples:</text>
145
+ <text dimColor fg="gray"> • https://github.com/pytorch/examples</text>
146
+ <text dimColor fg="gray"> • https://github.com/huggingface/transformers</text>
147
+ <text dimColor fg="gray"> • https://github.com/openai/whisper</text>
148
+ </box>
149
+ <box marginTop={1}>
150
+ <text dimColor fg="gray">Press Enter to continue • Esc to go back • Ctrl+C to exit</text>
151
+ </box>
152
+ </box>
153
+ );
154
+ };
155
+
156
+ const ProviderSelection = ({ selectedIndex, repoUrl }) => {
157
+ return (
158
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
159
+ <box borderStyle="single" padding={1} marginBottom={1}>
160
+ <text bold>Select Sandbox Provider</text>
161
+ </box>
162
+ <box marginBottom={1}>
163
+ <text bold>Repository: </text>
164
+ <text fg="green">{repoUrl}</text>
165
+ </box>
166
+ {providerOptions.map((option, index) => (
167
+ <box key={index} marginY={0} marginBottom={0}>
168
+ {index === selectedIndex ? (
169
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={50} style={{ justifyContent: 'center' }}>
170
+ <text bold fg="cyan">{option.name}</text>
171
+ </box>
172
+ ) : (
173
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={50} style={{ justifyContent: 'center' }}>
174
+ <text dimColor>{option.name}</text>
175
+ </box>
176
+ )}
177
+ </box>
178
+ ))}
179
+ <box marginTop={1}>
180
+ <text dimColor fg="gray">Press Enter to select • Esc to go back</text>
181
+ </box>
182
+ </box>
183
+ );
184
+ };
185
+
186
+ const GpuSelection = ({ selectedIndex, repoUrl, provider }) => {
187
+ return (
188
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
189
+ <box borderStyle="single" padding={1} marginBottom={1}>
190
+ <text bold>Select GPU Configuration</text>
191
+ </box>
192
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
193
+ <box><text bold>Repository: </text><text fg="green">{repoUrl}</text></box>
194
+ <box><text bold>Provider: </text><text fg="green">{provider}</text></box>
195
+ </box>
196
+ {gpuOptions.map((option, index) => (
197
+ <box key={index} marginY={0} marginBottom={0}>
198
+ {index === selectedIndex ? (
199
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={30} style={{ justifyContent: 'center' }}>
200
+ <text bold fg="cyan">{option.name}</text>
201
+ </box>
202
+ ) : (
203
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={30} style={{ justifyContent: 'center' }}>
204
+ <text dimColor>{option.name}</text>
205
+ </box>
206
+ )}
207
+ </box>
208
+ ))}
209
+ <box marginTop={1}>
210
+ <text dimColor fg="gray">Press Enter to select • Esc to go back</text>
211
+ </box>
212
+ </box>
213
+ );
214
+ };
215
+
216
+ const GpuCountSelection = ({ selectedIndex, gpuType }) => {
217
+ return (
218
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
219
+ <box borderStyle="single" padding={1} marginBottom={1}>
220
+ <text bold>Select GPU Count</text>
221
+ </box>
222
+ <box marginBottom={1}>
223
+ <text bold>GPU Type: </text>
224
+ <text fg="green">{gpuType}</text>
225
+ </box>
226
+ {gpuCountOptions.map((count, index) => (
227
+ <box key={index} marginY={0} marginBottom={0}>
228
+ {index === selectedIndex ? (
229
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={16} style={{ justifyContent: 'center' }}>
230
+ <text bold fg="cyan">{count} GPU{count > 1 ? 's' : ''}</text>
231
+ </box>
232
+ ) : (
233
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={16} style={{ justifyContent: 'center' }}>
234
+ <text dimColor>{count} GPU{count > 1 ? 's' : ''}</text>
235
+ </box>
236
+ )}
237
+ </box>
238
+ ))}
239
+ <box marginTop={1}>
240
+ <text dimColor fg="gray">Press Enter to select • Esc to go back</text>
241
+ </box>
242
+ </box>
243
+ );
244
+ };
245
+
246
+ const Confirmation = ({ config }) => {
247
+ return (
248
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
249
+ <box borderStyle="single" padding={1} marginBottom={1}>
250
+ <text bold>Configuration Summary</text>
251
+ </box>
252
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
253
+ <text bold>Repository URL:</text>
254
+ <text fg="cyan">{config.repoUrl}</text>
255
+ </box>
256
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
257
+ <text bold>Sandbox Provider:</text>
258
+ <text fg="cyan">{config.sandboxProvider}</text>
259
+ </box>
260
+ {config.sandboxProvider === 'modal' && (
261
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
262
+ <text bold>GPU Configuration:</text>
263
+ <text fg="cyan">{config.gpuCount > 1 ? config.gpuCount + 'x ' : ''}{config.gpuType}</text>
264
+ </box>
265
+ )}
266
+ <box marginTop={2} marginBottom={1}>
267
+ <text bold fg="green">Press Enter to create sandbox in background</text>
268
+ </box>
269
+ <box>
270
+ <text dimColor fg="gray">Press Esc to go back and change settings</text>
271
+ </box>
272
+ </box>
273
+ );
274
+ };
275
+
276
+ const AuthChoice = ({ selectedIndex }) => {
277
+ const authOptions = ['Create new account', 'Login with existing account'];
278
+ return (
279
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
280
+ <box borderStyle="single" padding={1} marginBottom={1}>
281
+ <text bold>GitArsenal Authentication</text>
282
+ </box>
283
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
284
+ <text dimColor fg="gray">Create an account or login to use GitArsenal</text>
285
+ <text dimColor fg="gray">Your credentials will be saved locally</text>
286
+ </box>
287
+ {authOptions.map((option, index) => (
288
+ <box key={index} marginY={0} marginBottom={0}>
289
+ {index === selectedIndex ? (
290
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={42} style={{ justifyContent: 'center' }}>
291
+ <text bold fg="cyan">{option}</text>
292
+ </box>
293
+ ) : (
294
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={42} style={{ justifyContent: 'center' }}>
295
+ <text dimColor>{option}</text>
296
+ </box>
297
+ )}
298
+ </box>
299
+ ))}
300
+ <box marginTop={1}>
301
+ <text dimColor fg="gray">Press Enter to select • Esc to exit</text>
302
+ </box>
303
+ </box>
304
+ );
305
+ };
306
+
307
+ const LoginForm = ({ values, onInput, onSubmit }) => {
308
+ const fields = ['username', 'email', 'fullName', 'password'];
309
+ const labels = ['Username:', 'Email Address:', 'Full Name:', 'Password:'];
310
+
311
+ return (
312
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
313
+ <box borderStyle="single" padding={1} marginBottom={1}>
314
+ <text bold>Login</text>
315
+ </box>
316
+ {fields.map((field, index) => (
317
+ <box key={field} marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
318
+ <text bold>{labels[index]}</text>
319
+ <input
320
+ value={values[field] || ''}
321
+ onInput={(value) => onInput(field, value)}
322
+ onSubmit={index === fields.length - 1 ? onSubmit : undefined}
323
+ placeholder={field === 'password' ? '••••••••' : ''}
324
+ focused={index === 0}
325
+ width={50}
326
+ />
327
+ </box>
328
+ ))}
329
+ <box marginTop={1}>
330
+ <text dimColor fg="gray">Fill all fields and press Enter • Esc to go back</text>
331
+ </box>
332
+ </box>
333
+ );
334
+ };
335
+
336
+ const RegisterForm = ({ values, onInput, onSubmit }) => {
337
+ const fields = ['username', 'email', 'fullName', 'password', 'confirmPassword'];
338
+ const labels = ['Username:', 'Email Address:', 'Full Name:', 'Password (min 8 chars):', 'Confirm Password:'];
339
+
340
+ return (
341
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
342
+ <box borderStyle="single" padding={1} marginBottom={1}>
343
+ <text bold>Create New Account</text>
344
+ </box>
345
+ {fields.map((field, index) => (
346
+ <box key={field} marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
347
+ <text bold>{labels[index]}</text>
348
+ <input
349
+ value={values[field] || ''}
350
+ onInput={(value) => onInput(field, value)}
351
+ onSubmit={index === fields.length - 1 ? onSubmit : undefined}
352
+ placeholder={field.includes('password') ? '••••••••' : ''}
353
+ focused={index === 0}
354
+ width={50}
355
+ />
356
+ </box>
357
+ ))}
358
+ <box marginTop={1}>
359
+ <text dimColor fg="gray">Fill all fields and press Enter • Esc to go back</text>
360
+ </box>
361
+ </box>
362
+ );
363
+ };
364
+
365
+ const SandboxList = ({ sandboxes, selectedIndex }) => {
366
+ const maxVisibleItems = 10;
367
+ const totalSandboxes = sandboxes.length;
368
+
369
+ // Calculate scroll offset to keep selected item in view
370
+ const scrollOffset = Math.max(0, Math.min(
371
+ selectedIndex - Math.floor(maxVisibleItems / 2),
372
+ Math.max(0, totalSandboxes - maxVisibleItems)
373
+ ));
374
+
375
+ const visibleSandboxes = sandboxes.slice(scrollOffset, scrollOffset + maxVisibleItems);
376
+ const showScrollIndicator = totalSandboxes > maxVisibleItems;
377
+
378
+ return (
379
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
380
+ <box borderStyle="single" padding={1} marginBottom={1}>
381
+ <text bold>Running Sandboxes</text>
382
+ </box>
383
+ {sandboxes.length === 0 ? (
384
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
385
+ <text dimColor fg="gray">No sandboxes running.</text>
386
+ <box marginTop={1}>
387
+ <text dimColor fg="gray">Press Esc to go back to menu</text>
388
+ </box>
389
+ </box>
390
+ ) : (
391
+ <>
392
+ {showScrollIndicator && (
393
+ <box marginBottom={1}>
394
+ <text dimColor fg="gray">Showing {scrollOffset + 1}-{Math.min(scrollOffset + maxVisibleItems, totalSandboxes)} of {totalSandboxes}</text>
395
+ </box>
396
+ )}
397
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
398
+ {visibleSandboxes.map((sandbox, visibleIndex) => {
399
+ const actualIndex = scrollOffset + visibleIndex;
400
+ const statusColor = sandbox.status === 'running' ? 'green' :
401
+ sandbox.status === 'initializing' ? 'yellow' : 'red';
402
+ const statusIcon = sandbox.status === 'running' ? '[OK]' :
403
+ sandbox.status === 'initializing' ? '[...]' : '[FAIL]';
404
+ return (
405
+ <box key={sandbox.id} marginY={0} marginBottom={0} style={{ flexDirection: 'column', alignItems: 'center' }}>
406
+ {actualIndex === selectedIndex ? (
407
+ <box borderStyle="single" borderColor="cyan" paddingX={2} paddingY={0} width={60} style={{ flexDirection: 'column', alignItems: 'center' }}>
408
+ <text bold fg="cyan">#{sandbox.id} {statusIcon} {sandbox.repo}</text>
409
+ <text fg="cyan">{sandbox.status} | {sandbox.provider} | {sandbox.gpu || 'N/A'}</text>
410
+ </box>
411
+ ) : (
412
+ <box borderStyle="single" borderColor="gray" paddingX={2} paddingY={0} width={60} style={{ flexDirection: 'column', alignItems: 'center' }}>
413
+ <text dimColor>#{sandbox.id} {statusIcon} {sandbox.repo}</text>
414
+ <text dimColor fg={statusColor}>{sandbox.status}</text>
415
+ </box>
416
+ )}
417
+ </box>
418
+ );
419
+ })}
420
+ </box>
421
+ {showScrollIndicator && (
422
+ <box marginTop={1}>
423
+ <text dimColor fg="gray">↑↓ to scroll • More {scrollOffset + maxVisibleItems < totalSandboxes ? '↓' : ''}{scrollOffset > 0 ? '↑' : ''}</text>
424
+ </box>
425
+ )}
426
+ <box marginTop={2} style={{ flexDirection: 'column', alignItems: 'center' }}>
427
+ <text bold>Controls:</text>
428
+ <text dimColor fg="gray">↑↓ - Navigate • Enter - View logs • D - Delete</text>
429
+ <text dimColor fg="gray">R - Refresh • Esc - Back to menu</text>
430
+ </box>
431
+ </>
432
+ )}
433
+ </box>
434
+ );
435
+ };
436
+
437
+ const SandboxLogs = ({ sandbox }) => {
438
+ const allLogLines = (sandbox.logs || 'No logs yet...').split('\n');
439
+ const totalLines = allLogLines.length;
440
+
441
+ return (
442
+ <box style={{ flexDirection: 'column', alignItems: 'center' }}>
443
+ <box borderStyle="single" padding={1} marginBottom={1}>
444
+ <text bold>Sandbox #{sandbox.id} Logs - {sandbox.repo}</text>
445
+ </box>
446
+ <box marginBottom={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
447
+ <box>
448
+ <text>Status: </text>
449
+ <text fg={sandbox.status === 'running' ? 'green' : sandbox.status === 'initializing' ? 'yellow' : 'red'}>
450
+ {sandbox.status}
451
+ </text>
452
+ </box>
453
+ <box>
454
+ <text dimColor fg="gray">Total lines: {totalLines}</text>
455
+ </box>
456
+ </box>
457
+ <scrollbox width={84} height={17} borderStyle="single" stickyScroll="bottom">
458
+ <box style={{ flexDirection: 'column' }}>
459
+ {allLogLines.map((line, index) => (
460
+ <text key={index} dimColor={!line.trim()}>{line || ' '}</text>
461
+ ))}
462
+ </box>
463
+ </scrollbox>
464
+ <box marginTop={1} style={{ flexDirection: 'column', alignItems: 'center' }}>
465
+ <text bold>Controls:</text>
466
+ <text dimColor fg="gray">Mouse Wheel or Arrow Keys - Scroll logs</text>
467
+ <text dimColor fg="gray">Esc - Back to sandbox list</text>
468
+ </box>
469
+ </box>
470
+ );
471
+ };
472
+
473
+ const App = () => {
474
+ const [selectedIndex, setSelectedIndex] = useState(0);
475
+ const [screen, setScreen] = useState('menu');
476
+ const [repoUrl, setRepoUrl] = useState('');
477
+ const [config, setConfig] = useState({
478
+ repoUrl: '',
479
+ gpuType: 'A10G',
480
+ gpuCount: 1,
481
+ sandboxProvider: 'modal'
482
+ });
483
+ const [sandboxes, setSandboxes] = useState([]);
484
+ const [sandboxIdCounter, setSandboxIdCounter] = useState(1);
485
+ const [statusMessage, setStatusMessage] = useState('');
486
+ const [viewingSandboxId, setViewingSandboxId] = useState(null);
487
+ const [userCredentials, setUserCredentials] = useState(null);
488
+ const [authFormValues, setAuthFormValues] = useState({});
489
+
490
+ // Load user credentials on mount
491
+ useEffect(() => {
492
+ const credentials = loadUserCredentials();
493
+ if (credentials) {
494
+ setUserCredentials(credentials);
495
+ setStatusMessage(`Welcome back, ${credentials.userName}!`);
496
+ setTimeout(() => setStatusMessage(''), 3000);
497
+ }
498
+ }, []);
499
+
500
+
501
+ const createSandbox = () => {
502
+ const sandboxId = sandboxIdCounter;
503
+ setSandboxIdCounter(prev => prev + 1);
504
+
505
+ const newSandbox = {
506
+ id: sandboxId,
507
+ repo: config.repoUrl.split('/').pop() || config.repoUrl,
508
+ fullRepo: config.repoUrl,
509
+ provider: config.sandboxProvider,
510
+ gpu: config.sandboxProvider === 'modal' ? `${config.gpuCount}x ${config.gpuType}` : null,
511
+ status: 'initializing',
512
+ startTime: new Date(),
513
+ logs: 'Starting sandbox...\n'
514
+ };
515
+
516
+ setSandboxes(prev => [...prev, newSandbox]);
517
+
518
+ const args = [
519
+ '--yes',
520
+ '--repo', config.repoUrl,
521
+ '--sandbox-provider', config.sandboxProvider
522
+ ];
523
+
524
+ if (config.sandboxProvider === 'modal') {
525
+ args.push('--gpu', config.gpuType);
526
+ args.push('--gpu-count', config.gpuCount.toString());
527
+ }
528
+
529
+ const cliPath = join(__dirname, '..', 'bin', 'gitarsenal.js');
530
+
531
+ const child = spawn('node', [cliPath, ...args], {
532
+ cwd: join(__dirname, '..'),
533
+ detached: true,
534
+ stdio: 'pipe'
535
+ });
536
+
537
+ // Store process reference for cleanup
538
+ setSandboxes(prev => prev.map(s =>
539
+ s.id === sandboxId ? { ...s, process: child } : s
540
+ ));
541
+
542
+ let output = '';
543
+ child.stdout.on('data', (data) => {
544
+ const text = data.toString();
545
+ output += text;
546
+
547
+ // Append to logs
548
+ setSandboxes(prev => prev.map(s =>
549
+ s.id === sandboxId ? { ...s, logs: (s.logs || '') + text } : s
550
+ ));
551
+
552
+ if (output.includes('Sandbox created') || output.includes('successfully')) {
553
+ setSandboxes(prev => prev.map(s =>
554
+ s.id === sandboxId ? { ...s, status: 'running' } : s
555
+ ));
556
+ }
557
+ });
558
+
559
+ child.stderr.on('data', (data) => {
560
+ const text = data.toString();
561
+ output += text;
562
+
563
+ // Append to logs
564
+ setSandboxes(prev => prev.map(s =>
565
+ s.id === sandboxId ? { ...s, logs: (s.logs || '') + text } : s
566
+ ));
567
+ });
568
+
569
+ child.on('close', (code) => {
570
+ setSandboxes(prev => prev.map(s =>
571
+ s.id === sandboxId ? {
572
+ ...s,
573
+ status: code === 0 ? 'running' : 'failed',
574
+ logs: (s.logs || '') + `\n\n[Process exited with code ${code}]`
575
+ } : s
576
+ ));
577
+ });
578
+
579
+ setStatusMessage(`Sandbox #${sandboxId} started in background!`);
580
+ setTimeout(() => setStatusMessage(''), 3000);
581
+
582
+ setScreen('menu');
583
+ setSelectedIndex(0);
584
+ setRepoUrl('');
585
+ setConfig({
586
+ repoUrl: '',
587
+ gpuType: 'A10G',
588
+ gpuCount: 1,
589
+ sandboxProvider: 'modal'
590
+ });
591
+ };
592
+
593
+ useKeyboard((key) => {
594
+ // Always handle Ctrl+C
595
+ if (key.ctrl && key.name === 'c') {
596
+ process.exit(0);
597
+ }
598
+
599
+ // Don't handle keyboard events when input is focused
600
+ if (screen === 'repoInput') {
601
+ if (key.name === 'escape') {
602
+ setScreen('menu');
603
+ setSelectedIndex(0);
604
+ }
605
+ // Let the input component handle all other keys
606
+ return;
607
+ }
608
+
609
+ if (screen === 'menu') {
610
+ if (key.name === 'up') {
611
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : menuItems.length - 1));
612
+ } else if (key.name === 'down') {
613
+ setSelectedIndex((prev) => (prev < menuItems.length - 1 ? prev + 1 : 0));
614
+ } else if (key.name === 'return' || key.name === 'enter') {
615
+ if (selectedIndex === 0) {
616
+ setScreen('repoInput');
617
+ setRepoUrl('');
618
+ } else if (selectedIndex === 1) {
619
+ setScreen('sandboxList');
620
+ setSelectedIndex(0);
621
+ } else if (selectedIndex === menuItems.length - 1) {
622
+ process.exit(0);
623
+ }
624
+ }
625
+ } else if (screen === 'providerSelection') {
626
+ if (key.name === 'up') {
627
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : providerOptions.length - 1));
628
+ } else if (key.name === 'down') {
629
+ setSelectedIndex((prev) => (prev < providerOptions.length - 1 ? prev + 1 : 0));
630
+ } else if (key.name === 'return' || key.name === 'enter') {
631
+ setConfig(prev => ({ ...prev, sandboxProvider: providerOptions[selectedIndex].value }));
632
+ if (providerOptions[selectedIndex].value === 'modal') {
633
+ setScreen('gpuSelection');
634
+ setSelectedIndex(0);
635
+ } else {
636
+ setScreen('confirmation');
637
+ }
638
+ } else if (key.name === 'escape') {
639
+ setScreen('repoInput');
640
+ }
641
+ } else if (screen === 'gpuSelection') {
642
+ if (key.name === 'up') {
643
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : gpuOptions.length - 1));
644
+ } else if (key.name === 'down') {
645
+ setSelectedIndex((prev) => (prev < gpuOptions.length - 1 ? prev + 1 : 0));
646
+ } else if (key.name === 'return' || key.name === 'enter') {
647
+ setConfig(prev => ({ ...prev, gpuType: gpuOptions[selectedIndex].value }));
648
+ setScreen('gpuCountSelection');
649
+ setSelectedIndex(0);
650
+ } else if (key.name === 'escape') {
651
+ setScreen('providerSelection');
652
+ setSelectedIndex(0);
653
+ }
654
+ } else if (screen === 'gpuCountSelection') {
655
+ if (key.name === 'up') {
656
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : gpuCountOptions.length - 1));
657
+ } else if (key.name === 'down') {
658
+ setSelectedIndex((prev) => (prev < gpuCountOptions.length - 1 ? prev + 1 : 0));
659
+ } else if (key.name === 'return' || key.name === 'enter') {
660
+ setConfig(prev => ({ ...prev, gpuCount: gpuCountOptions[selectedIndex] }));
661
+ setScreen('confirmation');
662
+ } else if (key.name === 'escape') {
663
+ setScreen('gpuSelection');
664
+ setSelectedIndex(0);
665
+ }
666
+ } else if (screen === 'confirmation') {
667
+ if (key.name === 'return' || key.name === 'enter') {
668
+ // Check if user is logged in before creating sandbox
669
+ if (!userCredentials) {
670
+ setScreen('authChoice');
671
+ setSelectedIndex(0);
672
+ } else {
673
+ createSandbox();
674
+ }
675
+ } else if (key.name === 'escape') {
676
+ if (config.sandboxProvider === 'modal') {
677
+ setScreen('gpuCountSelection');
678
+ } else {
679
+ setScreen('providerSelection');
680
+ }
681
+ setSelectedIndex(0);
682
+ }
683
+ } else if (screen === 'authChoice') {
684
+ if (key.name === 'up') {
685
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 1));
686
+ } else if (key.name === 'down') {
687
+ setSelectedIndex((prev) => (prev < 1 ? prev + 1 : 0));
688
+ } else if (key.name === 'return' || key.name === 'enter') {
689
+ if (selectedIndex === 0) {
690
+ setScreen('register');
691
+ setAuthFormValues({});
692
+ } else {
693
+ setScreen('login');
694
+ setAuthFormValues({});
695
+ }
696
+ } else if (key.name === 'escape') {
697
+ process.exit(0);
698
+ }
699
+ } else if (screen === 'login') {
700
+ if (key.name === 'escape') {
701
+ setScreen('authChoice');
702
+ setSelectedIndex(0);
703
+ setAuthFormValues({});
704
+ }
705
+ // Submit is handled by input component's onSubmit
706
+ } else if (screen === 'register') {
707
+ if (key.name === 'escape') {
708
+ setScreen('authChoice');
709
+ setSelectedIndex(0);
710
+ setAuthFormValues({});
711
+ }
712
+ // Submit is handled by input component's onSubmit
713
+ } else if (screen === 'sandboxList') {
714
+ if (key.name === 'up' && sandboxes.length > 0) {
715
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : sandboxes.length - 1));
716
+ } else if (key.name === 'down' && sandboxes.length > 0) {
717
+ setSelectedIndex((prev) => (prev < sandboxes.length - 1 ? prev + 1 : 0));
718
+ } else if ((key.name === 'return' || key.name === 'enter') && sandboxes.length > 0) {
719
+ const sandbox = sandboxes[selectedIndex];
720
+ setViewingSandboxId(sandbox.id);
721
+ setScreen('sandboxLogs');
722
+ } else if (key.name === 'escape') {
723
+ setScreen('menu');
724
+ setSelectedIndex(0);
725
+ } else if (key.name === 'd' && sandboxes.length > 0) {
726
+ const sandbox = sandboxes[selectedIndex];
727
+ if (sandbox.process) {
728
+ sandbox.process.kill();
729
+ }
730
+ setSandboxes(prev => prev.filter(s => s.id !== sandbox.id));
731
+ if (selectedIndex >= sandboxes.length - 1) {
732
+ setSelectedIndex(Math.max(0, sandboxes.length - 2));
733
+ }
734
+ } else if (key.name === 'r') {
735
+ // Refresh - just re-render
736
+ }
737
+ } else if (screen === 'sandboxLogs') {
738
+ if (key.name === 'escape') {
739
+ setViewingSandboxId(null);
740
+ setScreen('sandboxList');
741
+ }
742
+ }
743
+ });
744
+
745
+ const handleRepoInputChange = (value) => {
746
+ setRepoUrl(value);
747
+ setConfig(prev => ({ ...prev, repoUrl: value }));
748
+ };
749
+
750
+ const handleRepoInputSubmit = () => {
751
+ if (repoUrl.trim()) {
752
+ setScreen('providerSelection');
753
+ setSelectedIndex(0);
754
+ }
755
+ };
756
+
757
+ const handleAuthFormInput = (field, value) => {
758
+ setAuthFormValues(prev => ({ ...prev, [field]: value }));
759
+ };
760
+
761
+ const handleLoginSubmit = () => {
762
+ const { username, email, fullName, password } = authFormValues;
763
+ if (username && email && fullName && password) {
764
+ // Save credentials
765
+ const saved = saveUserCredentials(username, fullName, email);
766
+ if (saved) {
767
+ setUserCredentials({ userId: username, userName: fullName, userEmail: email });
768
+ setStatusMessage(`Welcome, ${fullName}!`);
769
+ setTimeout(() => setStatusMessage(''), 3000);
770
+ createSandbox();
771
+ } else {
772
+ setStatusMessage('Failed to save credentials');
773
+ setTimeout(() => setStatusMessage(''), 3000);
774
+ }
775
+ }
776
+ };
777
+
778
+ const handleRegisterSubmit = () => {
779
+ const { username, email, fullName, password, confirmPassword } = authFormValues;
780
+
781
+ // Validation
782
+ if (!username || username.length < 3) {
783
+ setStatusMessage('Username must be at least 3 characters');
784
+ setTimeout(() => setStatusMessage(''), 3000);
785
+ return;
786
+ }
787
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
788
+ setStatusMessage('Please enter a valid email address');
789
+ setTimeout(() => setStatusMessage(''), 3000);
790
+ return;
791
+ }
792
+ if (!fullName) {
793
+ setStatusMessage('Full name is required');
794
+ setTimeout(() => setStatusMessage(''), 3000);
795
+ return;
796
+ }
797
+ if (!password || password.length < 8) {
798
+ setStatusMessage('Password must be at least 8 characters');
799
+ setTimeout(() => setStatusMessage(''), 3000);
800
+ return;
801
+ }
802
+ if (password !== confirmPassword) {
803
+ setStatusMessage('Passwords do not match');
804
+ setTimeout(() => setStatusMessage(''), 3000);
805
+ return;
806
+ }
807
+
808
+ // Save credentials
809
+ const saved = saveUserCredentials(username, fullName, email);
810
+ if (saved) {
811
+ setUserCredentials({ userId: username, userName: fullName, userEmail: email });
812
+ setStatusMessage(`Account created! Welcome, ${fullName}!`);
813
+ setTimeout(() => setStatusMessage(''), 3000);
814
+ createSandbox();
815
+ } else {
816
+ setStatusMessage('Failed to save credentials');
817
+ setTimeout(() => setStatusMessage(''), 3000);
818
+ }
819
+ };
820
+
821
+ const viewingSandbox = viewingSandboxId ? sandboxes.find(s => s.id === viewingSandboxId) : null;
822
+
823
+ return (
824
+ <box style={{ flexDirection: 'column', padding: 1, width: '100%' }}>
825
+ <Banner />
826
+ {statusMessage && (
827
+ <box marginBottom={1} style={{ flexDirection: 'row', justifyContent: 'center' }}>
828
+ <text fg="green">{statusMessage}</text>
829
+ </box>
830
+ )}
831
+ {screen === 'menu' && <Menu selectedIndex={selectedIndex} />}
832
+ {screen === 'repoInput' && <RepoInput value={repoUrl} onInput={handleRepoInputChange} onSubmit={handleRepoInputSubmit} />}
833
+ {screen === 'providerSelection' && <ProviderSelection selectedIndex={selectedIndex} repoUrl={config.repoUrl} />}
834
+ {screen === 'gpuSelection' && <GpuSelection selectedIndex={selectedIndex} repoUrl={config.repoUrl} provider={config.sandboxProvider} />}
835
+ {screen === 'gpuCountSelection' && <GpuCountSelection selectedIndex={selectedIndex} gpuType={config.gpuType} />}
836
+ {screen === 'confirmation' && <Confirmation config={config} />}
837
+ {screen === 'authChoice' && <AuthChoice selectedIndex={selectedIndex} />}
838
+ {screen === 'login' && <LoginForm values={authFormValues} onInput={handleAuthFormInput} onSubmit={handleLoginSubmit} />}
839
+ {screen === 'register' && <RegisterForm values={authFormValues} onInput={handleAuthFormInput} onSubmit={handleRegisterSubmit} />}
840
+ {screen === 'sandboxList' && <SandboxList sandboxes={sandboxes} selectedIndex={selectedIndex} />}
841
+ {screen === 'sandboxLogs' && viewingSandbox && <SandboxLogs sandbox={viewingSandbox} />}
842
+ </box>
843
+ );
844
+ };
845
+
846
+ render(<App />, {
847
+ enableMouse: true
848
+ });