UNPKG

gitarsenal-cli

Version:

CLI tool for creating Modal sandboxes with GitHub repositories

1,190 lines (1,045 loc) • 42.1 kB
#!/usr/bin/env node const { program } = require('commander'); const chalk = require('chalk'); const inquirer = require('inquirer'); const ora = require('ora'); const path = require('path'); const { version } = require('../package.json'); const { checkDependencies } = require('../lib/dependencies'); const { runContainer } = require('../lib/sandbox'); const updateNotifier = require('update-notifier'); const pkg = require('../package.json'); const boxen = require('boxen'); const { spawn } = require('child_process'); const fs = require('fs'); const https = require('https'); const http = require('http'); // Function to activate virtual environment function activateVirtualEnvironment() { const isWindows = process.platform === 'win32'; const venvPath = path.join(__dirname, '..', '.venv'); const statusFile = path.join(__dirname, '..', '.venv_status.json'); // Check if virtual environment exists if (!fs.existsSync(venvPath)) { console.log(chalk.red('āŒ Virtual environment not found. Please reinstall the package:')); console.log(chalk.yellow(' npm uninstall -g gitarsenal-cli')); console.log(chalk.yellow(' npm install -g gitarsenal-cli')); console.log(chalk.yellow('')); console.log(chalk.yellow('šŸ’” Or run the postinstall script manually:')); console.log(chalk.yellow(' cd /root/.nvm/versions/node/v22.18.0/lib/node_modules/gitarsenal-cli')); console.log(chalk.yellow(' node scripts/postinstall.js')); return false; } // Check if status file exists (indicates successful installation) if (fs.existsSync(statusFile)) { try { const status = JSON.parse(fs.readFileSync(statusFile, 'utf8')); } catch (error) { console.log(chalk.gray('āœ… Virtual environment found')); } } // Verify virtual environment structure - uv creates different structure let pythonPath, pipPath; // Check for uv-style virtual environment first const uvPythonPath = path.join(venvPath, 'bin', 'python'); const uvPipPath = path.join(venvPath, 'bin', 'pip'); // Check for traditional venv structure const traditionalPythonPath = isWindows ? path.join(venvPath, 'Scripts', 'python.exe') : path.join(venvPath, 'bin', 'python'); const traditionalPipPath = isWindows ? path.join(venvPath, 'Scripts', 'pip.exe') : path.join(venvPath, 'bin', 'pip'); // Determine which structure exists // console.log(chalk.gray(`šŸ” Checking virtual environment structure:`)); // console.log(chalk.gray(` Python: ${uvPythonPath} (exists: ${fs.existsSync(uvPythonPath)})`)); // console.log(chalk.gray(` Pip: ${uvPipPath} (exists: ${fs.existsSync(uvPipPath)})`)); // For uv virtual environments, we only need Python to exist // uv doesn't create a pip executable, it uses 'uv pip' instead if (fs.existsSync(uvPythonPath)) { pythonPath = uvPythonPath; pipPath = 'uv pip'; // Use uv pip instead of pip executable // console.log(chalk.gray('āœ… Found uv-style virtual environment')); } else if (fs.existsSync(traditionalPythonPath) && fs.existsSync(traditionalPipPath)) { pythonPath = traditionalPythonPath; pipPath = traditionalPipPath; console.log(chalk.gray('āœ… Found traditional virtual environment')); } else { console.log(chalk.red('āŒ Virtual environment structure not recognized')); console.log(chalk.gray('Expected Python at:')); console.log(chalk.gray(` ${uvPythonPath}`)); console.log(chalk.gray(` ${traditionalPythonPath}`)); console.log(chalk.yellow('šŸ’” Please reinstall the package')); return false; } // Update PATH to prioritize virtual environment const pathSeparator = isWindows ? ';' : ':'; const venvBinPath = path.dirname(pythonPath); // Use the same directory as the Python executable process.env.PATH = `${venvBinPath}${pathSeparator}${process.env.PATH}`; process.env.VIRTUAL_ENV = venvPath; process.env.PYTHONPATH = venvPath; // Set Python executable path for child processes process.env.PYTHON_EXECUTABLE = pythonPath; process.env.PIP_EXECUTABLE = pipPath; console.log(chalk.green('āœ… Virtual environment activated successfully')); return true; } // Lightweight preview of GPU/Torch/CUDA recommendations prior to GPU selection async function previewRecommendations(repoUrl, optsOrShowSummary = true) { const showSummary = typeof optsOrShowSummary === 'boolean' ? optsOrShowSummary : (optsOrShowSummary?.showSummary ?? true); const externalSignal = typeof optsOrShowSummary === 'object' ? optsOrShowSummary.abortSignal : undefined; const hideSpinner = typeof optsOrShowSummary === 'object' ? optsOrShowSummary.hideSpinner : false; const spinner = hideSpinner ? null : ora('Analyzing repository for GPU/Torch/CUDA recommendations...').start(); const previewTimeoutMs = Number(process.env.GITARSENAL_PREVIEW_TIMEOUT_MS || 90000); const controller = new AbortController(); const abortOnExternal = () => controller.abort(); const timeoutId = setTimeout(() => controller.abort(), previewTimeoutMs); // Add periodic spinner updates to show progress (only if we have a spinner) let elapsedTime = 0; const progressInterval = spinner ? setInterval(() => { elapsedTime += 10; const minutes = Math.floor(elapsedTime / 60); const seconds = elapsedTime % 60; const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; spinner.text = `Analyzing repository for GPU/Torch/CUDA recommendations... (${timeStr})`; }, 10000) : null; // Update every 10 seconds try { // Bridge external abort signal to our controller (for stopping spinner when full fetch returns) if (externalSignal) { if (externalSignal.aborted) controller.abort(); else externalSignal.addEventListener('abort', abortOnExternal, { once: true }); } const envUrl = process.env.GITARSENAL_API_URL; const endpoints = envUrl ? [envUrl] : ['https://www.gitarsenal.dev/api/best_gpu']; const payload = { repoUrl, gitingestData: { system_info: { platform: process.platform, python_version: process.version, detected_language: 'Unknown', detected_technologies: [], file_count: 0, repo_stars: 0, repo_forks: 0, primary_package_manager: 'Unknown', complexity_level: 'Unknown' }, repository_analysis: { summary: `Repository: ${repoUrl}`, tree: '', content_preview: '' }, success: true }, preview: true }; let data = null; let lastErrorText = ''; for (const url of endpoints) { try { if (spinner) spinner.text = `Analyzing (preview): ${url}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'GitArsenal-CLI/1.0' }, body: JSON.stringify(payload), redirect: 'follow', signal: controller.signal }); if (!res.ok) { const text = await res.text().catch(() => ''); lastErrorText = `${res.status} ${text.slice(0, 300)}`; continue; } data = await res.json().catch(() => null); if (data) break; } catch (err) { if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) { // Silent stop on external abort (e.g., full fetch succeeded) return null; } lastErrorText = err && err.message ? err.message : 'request failed'; continue; } } if (!data) { if (!hideSpinner) { console.log(chalk.yellow('āš ļø Preview unavailable (timeout or server error).')); if (lastErrorText) console.log(chalk.gray(`Reason: ${lastErrorText}`)); } return null; } if (showSummary) { printGpuTorchCudaSummary(data); } return data; } catch (e) { if (!(e && (e.name === 'AbortError' || e.code === 'ABORT_ERR')) && !hideSpinner) { console.log(chalk.yellow(`āš ļø Preview failed: ${e.message}`)); } return null; } finally { clearTimeout(timeoutId); if (progressInterval) clearInterval(progressInterval); if (spinner) spinner.stop(); if (externalSignal) externalSignal.removeEventListener('abort', abortOnExternal); } } function printGpuTorchCudaSummary(result) { try { console.log(chalk.bold('\nšŸ“Š RESULT SUMMARY (GPU/Torch/CUDA)')); console.log('────────────────────────────────────────────────────────'); const cuda = result.cudaRecommendation; if (cuda) { console.log(chalk.bold('šŸŽÆ CUDA Recommendation')); if (cuda.recommendedCudaVersion) console.log(` - CUDA: ${cuda.recommendedCudaVersion}`); if (Array.isArray(cuda.compatibleTorchVersions) && cuda.compatibleTorchVersions.length) console.log(` - Torch Compatibility: ${cuda.compatibleTorchVersions.join(', ')}`); if (cuda.dockerImage) console.log(` - Docker Image: ${cuda.dockerImage}`); if (Array.isArray(cuda.installCommands) && cuda.installCommands.length) { console.log(' - Install Commands:'); cuda.installCommands.forEach((c) => console.log(` $ ${c}`)); } if (cuda.notes) console.log(` - Notes: ${cuda.notes}`); console.log(); } const torch = result.torchRecommendation; if (torch) { console.log(chalk.bold('šŸ”„ PyTorch Recommendation')); if (torch.recommendedTorchVersion) console.log(` - Torch: ${torch.recommendedTorchVersion}`); if (torch.cudaVariant) console.log(` - CUDA Variant: ${torch.cudaVariant}`); if (torch.pipInstallCommand) { console.log(' - Install:'); console.log(` $ ${torch.pipInstallCommand}`); } if (Array.isArray(torch.extraPackages) && torch.extraPackages.length) console.log(` - Extra Packages: ${torch.extraPackages.join(', ')}`); if (torch.notes) console.log(` - Notes: ${torch.notes}`); console.log(); } const gpu = result.gpuRecommendation; if (gpu) { console.log(chalk.bold('šŸ–„ļø GPU Recommendation')); if (gpu.minimumVramGb !== undefined) console.log(` - Min VRAM: ${gpu.minimumVramGb} GB`); if (gpu.recommendedVramGb !== undefined) console.log(` - Recommended VRAM: ${gpu.recommendedVramGb} GB`); if (gpu.minComputeCapability) console.log(` - Min Compute Capability: ${gpu.minComputeCapability}`); if (Array.isArray(gpu.recommendedModels) && gpu.recommendedModels.length) console.log(` - Recommended Models: ${gpu.recommendedModels.join(', ')}`); if (Array.isArray(gpu.budgetOptions) && gpu.budgetOptions.length) console.log(` - Budget Options: ${gpu.budgetOptions.join(', ')}`); if (Array.isArray(gpu.cloudInstances) && gpu.cloudInstances.length) console.log(` - Cloud Instances: ${gpu.cloudInstances.join(', ')}`); if (gpu.notes) console.log(` - Notes: ${gpu.notes}`); console.log(); } } catch {} } // Helper to derive a default volume name from the repository URL function getDefaultVolumeName(repoUrl) { try { if (!repoUrl || typeof repoUrl !== 'string') return 'repo'; let url = repoUrl.trim(); // Remove trailing slash if (url.endsWith('/')) url = url.slice(0, -1); // Prefer everything after github.com/ (owner/repo) if (url.toLowerCase().includes('github.com')) { let after = url; const markerIndex = after.toLowerCase().indexOf('github.com'); after = after.slice(markerIndex + 'github.com'.length); // e.g., '/owner/repo.git' or ':owner/repo.git' // Strip leading separators while (after.startsWith('/') || after.startsWith(':')) after = after.slice(1); // Trim query/fragment if present const stopIdx = Math.min( ...[after.indexOf('?'), after.indexOf('#')].map(i => (i === -1 ? after.length : i)) ); after = after.slice(0, stopIdx); // Remove .git suffix if (after.toLowerCase().endsWith('.git')) after = after.slice(0, -4); // Sanitize: replace '/' with '_' first, then keep docker-friendly chars let vol = after.replace(/[\\/]+/g, '_') .replace(/[^a-zA-Z0-9_.-]/g, '_') .replace(/_+/g, '_') .toLowerCase(); return vol || 'repo'; } // Fallback behavior for non-GitHub URLs: use last path segment (repo name) let repoName = null; try { const u = new URL(url); const parts = u.pathname.split('/').filter(Boolean); repoName = parts[parts.length - 1] || null; } catch (_) { const parts = url.split(/[/:]/).filter(Boolean); repoName = parts[parts.length - 1] || null; } if (!repoName) return 'repo'; if (repoName.toLowerCase().endsWith('.git')) repoName = repoName.slice(0, -4); repoName = repoName .replace(/[^a-zA-Z0-9_.-]/g, '_') .replace(/_+/g, '_') .toLowerCase(); return repoName || 'repo'; } catch (_) { return 'repo'; } } // Full fetch to get both setup commands and recommendations in one request async function fetchFullSetupAndRecs(repoUrl) { // For now, just use the preview function but don't show summary to avoid duplicates // The Python implementation will handle setup commands return await previewRecommendations(repoUrl, { showSummary: false, hideSpinner: true }); } // Function to send user data to web application async function sendUserData(userId, userName, userEmail) { try { console.log(chalk.blue(`šŸ”— Attempting to register user: ${userName} (${userId})`)); const userData = { email: userEmail, // Use userId as email (assuming it's an email) name: userName, username: userId }; const data = JSON.stringify(userData); // Get webhook URL from config or use default let webhookUrl = 'https://www.gitarsenal.dev/api/users'; const configPath = path.join(__dirname, '..', 'config.json'); if (fs.existsSync(configPath)) { try { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (config.webhookUrl) { webhookUrl = config.webhookUrl; } } catch (error) { console.log(chalk.yellow('āš ļø Could not read config file, using default URL')); } } // Env var override has highest priority if (process.env.GITARSENAL_WEBHOOK_URL) { webhookUrl = process.env.GITARSENAL_WEBHOOK_URL; } console.log(chalk.gray(`šŸ“¦ Data: ${data}`)); const urlObj = new URL(webhookUrl); const options = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: urlObj.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'User-Agent': 'GitArsenal-CLI/1.0' } }; if (process.env.GITARSENAL_WEBHOOK_TOKEN) { options.headers['Authorization'] = `Bearer ${process.env.GITARSENAL_WEBHOOK_TOKEN}`; } return new Promise((resolve, reject) => { const client = urlObj.protocol === 'https:' ? https : http; const req = client.request(options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { console.log(chalk.green('āœ… User registered on GitArsenal dashboard')); resolve(responseData); } else if (res.statusCode === 409) { console.log(chalk.green('āœ… User already exists on GitArsenal dashboard')); resolve(responseData); } else { console.log(chalk.yellow(`āš ļø Failed to register user (status: ${res.statusCode})`)); console.log(chalk.gray(`Response: ${responseData}`)); resolve(responseData); } }); }); req.on('error', (err) => { console.log(chalk.red(`āŒ Could not connect to GitArsenal dashboard: ${err.message}`)); resolve(); }); req.setTimeout(10000, () => { console.log(chalk.red('āŒ Request timeout - could not connect to dashboard')); req.destroy(); resolve(); }); req.write(JSON.stringify(userData)); req.end(); }); } catch (error) { console.log(chalk.red(`āŒ Error registering user: ${error.message}`)); } } // Function to collect user credentials async function collectUserCredentials(options) { let userId = options.userId; let userName = options.userName; let userEmail = options.userEmail; // Check for config file first const os = require('os'); const userConfigDir = path.join(os.homedir(), '.gitarsenal'); const userConfigPath = path.join(userConfigDir, 'user-config.json'); if (fs.existsSync(userConfigPath)) { try { const config = JSON.parse(fs.readFileSync(userConfigPath, 'utf8')); if (config.userId && config.userName && config.userEmail) { // Check if the email is a fake one (contains @example.com) if (config.userEmail.includes('@example.com')) { console.log(chalk.yellow('āš ļø Detected placeholder email address. Please update your credentials.')); console.log(chalk.gray('We need your real email address for proper registration.')); // Prompt for real email address const emailUpdate = await inquirer.prompt([ { type: 'input', name: 'userEmail', message: 'Enter your real email address:', validate: (input) => { const email = input.trim(); if (email === '') return 'Email address is required'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return 'Please enter a valid email address (e.g., user@example.com)'; } return true; } } ]); // Update the config with real email config.userEmail = emailUpdate.userEmail; config.updatedAt = new Date().toISOString(); fs.writeFileSync(userConfigPath, JSON.stringify(config, null, 2)); console.log(chalk.green('āœ… Email address updated successfully!')); } userId = config.userId; userName = config.userName; userEmail = config.userEmail; console.log(chalk.green(`āœ… Welcome back, ${userName}!`)); return { userId, userName, userEmail }; } } catch (error) { console.log(chalk.yellow('āš ļø Could not read user config file')); } } // If not provided via CLI or config, prompt for them if (!userId || !userName || !userEmail) { console.log(chalk.blue('\nšŸ” GitArsenal Authentication')); console.log(chalk.gray('Create an account or login to use GitArsenal')); console.log(chalk.gray('Your credentials will be saved locally for future use.')); const authChoice = await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: 'Create new account', value: 'register' }, { name: 'Login with existing account', value: 'login' } ] } ]); if (authChoice.action === 'register') { console.log(chalk.blue('\nšŸ“ Create New Account')); const credentials = await inquirer.prompt([ { type: 'input', name: 'userId', message: 'Choose a username:', validate: (input) => { const username = input.trim(); if (username === '') return 'Username is required'; if (username.length < 3) return 'Username must be at least 3 characters'; if (!/^[a-zA-Z0-9_-]+$/.test(username)) return 'Username can only contain letters, numbers, _ and -'; return true; } }, { type: 'input', name: 'userEmail', message: 'Enter your email address:', validate: (input) => { const email = input.trim(); if (email === '') return 'Email address is required'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return 'Please enter a valid email address (e.g., user@example.com)'; } return true; } }, { type: 'input', name: 'userName', message: 'Enter your full name:', validate: (input) => input.trim() !== '' ? true : 'Name is required' }, { type: 'password', name: 'password', message: 'Create a password (min 8 characters):', validate: (input) => { if (input.length < 8) return 'Password must be at least 8 characters'; return true; } }, { type: 'password', name: 'confirmPassword', message: 'Confirm your password:', validate: (input, answers) => { if (input !== answers.password) return 'Passwords do not match'; return true; } } ]); userId = credentials.userId; userName = credentials.userName; userEmail = credentials.userEmail; console.log(chalk.green('āœ… Account created successfully!')); } else { console.log(chalk.blue('\nšŸ”‘ Login')); const credentials = await inquirer.prompt([ { type: 'input', name: 'userId', message: 'Enter your username:', validate: (input) => { const username = input.trim(); if (username === '') return 'Username is required'; if (username.length < 3) return 'Username must be at least 3 characters'; if (!/^[a-zA-Z0-9_-]+$/.test(username)) return 'Username can only contain letters, numbers, _ and -'; return true; } }, { type: 'input', name: 'userEmail', message: 'Enter your email address:', validate: (input) => { const email = input.trim(); if (email === '') return 'Email address is required'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return 'Please enter a valid email address (e.g., user@example.com)'; } return true; } }, { type: 'input', name: 'userName', message: 'Enter your full name:', validate: (input) => input.trim() !== '' ? true : 'Name is required' }, { type: 'password', name: 'password', message: 'Enter your password:', validate: (input) => input.trim() !== '' ? true : 'Password is required' } ]); userId = credentials.userId; userName = credentials.userName; userEmail = credentials.userEmail; console.log(chalk.green('āœ… Login successful!')); } // Save credentials to user-specific config file try { // Ensure user config directory exists if (!fs.existsSync(userConfigDir)) { fs.mkdirSync(userConfigDir, { recursive: true }); } const config = { userId, userName, userEmail, savedAt: new Date().toISOString() }; fs.writeFileSync(userConfigPath, JSON.stringify(config, null, 2)); console.log(chalk.green('āœ… Credentials saved locally')); } catch (error) { console.log(chalk.yellow('āš ļø Could not save credentials locally')); } } return { userId, userName, userEmail }; } // Activate virtual environment activateVirtualEnvironment(); // Check for updates updateNotifier({ pkg }).notify(); // Display banner try { const bannerPath = path.join(__dirname, '..', 'ascii_banner.txt'); const banner = fs.readFileSync(bannerPath, 'utf8'); console.log(chalk.green(banner)); } catch (error) { // Fallback to simple banner if ASCII art file is not found console.log(boxen(chalk.bold.green('GitArsenal CLI') + '\n' + chalk.blue('Create GPU-accelerated development environments'), { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'green' })); } // Set up main command program .version(version) .description('GitArsenal CLI - Create GPU-accelerated development environments'); // Keys management command const keysCmd = program .command('keys') .description('Manage API keys for services'); keysCmd .command('add') .description('Add an API key') .option('-s, --service <service>', 'Service name (any service supported)') .option('-k, --key <key>', 'API key (if not provided, will prompt)') .action(async (options) => { // Set webhook override if provided via env if (process.env.GITARSENAL_WEBHOOK_URL) { console.log(chalk.gray(`šŸ”— Using webhook: ${process.env.GITARSENAL_WEBHOOK_URL}`)); } await handleKeysAdd(options); }); keysCmd .command('list') .description('List saved API keys') .action(async () => { await handleKeysList(); }); keysCmd .command('view') .description('View a specific API key (masked)') .option('-s, --service <service>', 'Service name') .action(async (options) => { await handleKeysView(options); }); keysCmd .command('delete') .description('Delete an API key') .option('-s, --service <service>', 'Service name') .action(async (options) => { await handleKeysDelete(options); }); // For backward compatibility, support running without a subcommand program .option('-r, --repo <url>', 'GitHub repository URL') .option('-g, --gpu <type>', 'GPU type (A10G, A100, H100, T4, L4, L40S, V100)') .option('-v, --volume <n>', 'Name of persistent volume') .option('-y, --yes', 'Skip confirmation prompts') .option('-m, --manual', 'Disable automatic setup command detection') .option('--show-examples', 'Show usage examples') .option('--user-id <id>', 'User ID for tracking') .option('--user-name <name>', 'User name for tracking') .action(async (options) => { // If options are provided directly, run the container command if (options.repo || options.showExamples || process.argv.length <= 3) { await runContainerCommand(options); } }); program.parse(process.argv); async function runContainerCommand(options) { try { // console.log(chalk.blue('šŸ” DEBUG: runContainerCommand called with options:'), options); // If show-examples flag is set, just show examples and exit if (options.showExamples) { await runContainer({ showExamples: true }); return; } // Collect user credentials const userCredentials = await collectUserCredentials(options); const { userId, userName, userEmail } = userCredentials; // Register user on dashboard immediately after collecting credentials console.log(chalk.blue('\nšŸ“ Registering user on GitArsenal dashboard...')); // Send user data immediately so the dashboard records users await sendUserData(userId, userName, userEmail); // Check for required dependencies const spinner = ora('Checking dependencies...').start(); const dependenciesOk = await checkDependencies(); if (!dependenciesOk) { spinner.fail('Missing dependencies. Please install them and try again.'); process.exit(1); } spinner.succeed('Dependencies checked'); // If repo URL not provided, prompt for it let repoUrl = options.repoUrl || options.repo; let gpuType = options.gpu; let volumeName = options.volumeName || options.volume; let skipConfirmation = options.yes; let useApi = !options.manual; let setupCommands = options.setupCommands || []; if (!repoUrl) { const answers = await inquirer.prompt([ { type: 'input', name: 'repoUrl', message: 'Enter GitHub repository URL:', validate: (input) => input.trim() !== '' ? true : 'Repository URL is required' } ]); repoUrl = answers.repoUrl; } // Attempt full fetch first to get both commands and recommendations; now start preview concurrently if (useApi && repoUrl) { // Start a main spinner that will show overall progress const mainSpinner = ora('Analyzing repository...').start(); try { // Start preview immediately so we get early feedback; suppress summary here to avoid duplicates. // Provide an AbortController so we can stop the preview spinner as soon as full fetch returns. const previewAbort = new AbortController(); mainSpinner.text = 'Analyzing repository for GPU/Torch/CUDA recommendations...'; const previewPromise = previewRecommendations(repoUrl, { showSummary: false, abortSignal: previewAbort.signal, hideSpinner: true }).catch(() => null); // Run full fetch in parallel; prefer its results if available. mainSpinner.text = 'Finding the best machine for your code...'; const fullData = await fetchFullSetupAndRecs(repoUrl).catch(() => null); if (fullData) { // Stop preview spinner immediately since we have a response previewAbort.abort(); mainSpinner.succeed('Analysis complete!'); printGpuTorchCudaSummary(fullData); if (Array.isArray(fullData.commands) && fullData.commands.length) { setupCommands = fullData.commands; // Disable auto-detection since we already have commands useApi = false; } } else { // Full fetch failed, wait for preview and show its results mainSpinner.text = 'Waiting for preview analysis to complete...'; const previewData = await previewPromise; if (previewData) { mainSpinner.succeed('Preview analysis complete!'); printGpuTorchCudaSummary(previewData); } else { mainSpinner.fail('Analysis failed - both preview and full analysis timed out or failed'); console.log(chalk.yellow('āš ļø Unable to analyze repository automatically.')); console.log(chalk.gray('You can still proceed with manual setup commands.')); } } } catch (error) { mainSpinner.fail(`Analysis failed: ${error.message}`); console.log(chalk.yellow('āš ļø Unable to analyze repository automatically.')); console.log(chalk.gray('You can still proceed with manual setup commands.')); } } // Prompt for GPU type if not specified if (!gpuType) { const gpuAnswers = await inquirer.prompt([ { type: 'list', name: 'gpuType', message: 'Select GPU type:', choices: [ { name: 'T4 (16GB VRAM)', value: 'T4' }, { name: 'L4 (24GB VRAM)', value: 'L4' }, { name: 'A10G (24GB VRAM)', value: 'A10G' }, { name: 'A100-40 (40GB VRAM)', value: 'A100-40GB' }, { name: 'A100-80 (80GB VRAM)', value: 'A100-80GB' }, { name: 'L40S (48GB VRAM)', value: 'L40S' }, { name: 'H100 (80GB VRAM)', value: 'H100' }, { name: 'H200 (141GB VRAM)', value: 'H200' }, { name: 'B200 (141GB VRAM)', value: 'B200' } ], default: 'A10G' } ]); gpuType = gpuAnswers.gpuType; } // Prompt for persistent volume if (!volumeName && !skipConfirmation) { const volumeAnswers = await inquirer.prompt([ { type: 'confirm', name: 'useVolume', message: 'Use persistent volume for faster installs?', default: true }, { type: 'input', name: 'volumeName', message: 'Enter volume name:', default: getDefaultVolumeName(repoUrl), when: (answers) => answers.useVolume } ]); if (volumeAnswers.useVolume) { volumeName = getDefaultVolumeName(repoUrl); } } else if (!volumeName && skipConfirmation) { // If --yes flag is used and no volume specified, use default volumeName = getDefaultVolumeName(repoUrl); } // Ask about setup command detection if not specified via CLI if (!options.manual && !options.yes && setupCommands.length === 0) { const apiAnswers = await inquirer.prompt([ { type: 'confirm', name: 'useApi', message: 'Automatically detect setup commands for this repository?', default: true } ]); useApi = apiAnswers.useApi; } else if (options.yes) { // If --yes flag is used, default to using API for setup command detection useApi = true; } // Only prompt for custom commands if auto-detection is disabled and no commands provided if (!useApi && setupCommands.length === 0) { const setupAnswers = await inquirer.prompt([ { type: 'confirm', name: 'useCustomCommands', message: 'Provide custom setup commands?', default: true } ]); if (setupAnswers.useCustomCommands) { console.log(chalk.yellow('Enter setup commands (one per line). Type "done" on a new line when finished:')); const commandsInput = await inquirer.prompt([ { type: 'editor', name: 'commands', message: 'Enter setup commands:', default: '# Enter one command per line\n# Example: pip install -r requirements.txt\n' } ]); setupCommands = commandsInput.commands .split('\n') .filter(line => line.trim() !== '' && !line.trim().startsWith('#')); } } // Confirm settings (configuration will be shown by Python script after GPU selection) if (!skipConfirmation) { const confirmAnswers = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: 'Proceed with these settings?', default: true } ]); if (!confirmAnswers.proceed) { console.log(chalk.yellow('Operation cancelled by user.')); process.exit(0); } } // Log command start const commandString = `gitarsenal container ${repoUrl ? `--repo ${repoUrl}` : ''} ${gpuType ? `--gpu ${gpuType}` : ''}`; // Run the container try { await runContainer({ repoUrl, gpuType, volumeName, setupCommands, useApi, yes: skipConfirmation, userId, userName, userEmail }); } catch (containerError) { throw containerError; } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } async function handleKeysAdd(options) { try { // Collect user credentials for keys operations const userCredentials = await collectUserCredentials(options); const { userId, userName } = userCredentials; // Register user on dashboard console.log(chalk.blue('\nšŸ“ Registering user on GitArsenal dashboard...')); // Note: User data will be sent by the Python script after authentication // await sendUserData(userId, userName); const spinner = ora('Adding API key...').start(); let service = options.service; let key = options.key; if (!service) { spinner.stop(); const serviceAnswer = await inquirer.prompt([ { type: 'input', name: 'service', message: 'Enter service name:', validate: (input) => input.trim() !== '' ? true : 'Service name is required' } ]); service = serviceAnswer.service; } if (!key) { spinner.stop(); const keyAnswer = await inquirer.prompt([ { type: 'password', name: 'key', message: `Enter ${service} API key:`, mask: '*' } ]); key = keyAnswer.key; } // Call Python script to add the key const scriptPath = path.join(__dirname, '..', 'python', 'gitarsenal_keys.py'); const pythonExecutable = process.env.PYTHON_EXECUTABLE || 'python'; const pythonProcess = spawn(pythonExecutable, [ scriptPath, 'add', '--service', service, '--key', key ], { stdio: 'pipe' }); let output = ''; pythonProcess.stdout.on('data', (data) => { output += data.toString(); }); pythonProcess.stderr.on('data', (data) => { output += data.toString(); }); pythonProcess.on('close', async (code) => { if (code === 0) { spinner.succeed(`API key for ${service} added successfully`); } else { spinner.fail(`Failed to add API key: ${output}`); } }); } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } async function handleKeysList() { try { // Collect user credentials for keys operations const userCredentials = await collectUserCredentials({}); const { userId, userName } = userCredentials; // Register user on dashboard console.log(chalk.blue('\nšŸ“ Registering user on GitArsenal dashboard...')); // Note: User data will be sent by the Python script after authentication // await sendUserData(userId, userName); const spinner = ora('Fetching API keys...').start(); // Call Python script to list keys const scriptPath = path.join(__dirname, '..', 'python', 'gitarsenal_keys.py'); const pythonExecutable = process.env.PYTHON_EXECUTABLE || 'python'; const pythonProcess = spawn(pythonExecutable, [ scriptPath, 'list' ], { stdio: 'inherit' }); pythonProcess.on('close', async (code) => { if (code !== 0) { spinner.fail('Failed to list API keys'); } else { spinner.stop(); } }); } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } async function handleKeysView(options) { try { // Collect user credentials for keys operations const userCredentials = await collectUserCredentials(options); const { userId, userName } = userCredentials; // Register user on dashboard console.log(chalk.blue('\nšŸ“ Registering user on GitArsenal dashboard...')); // Note: User data will be sent by the Python script after authentication // await sendUserData(userId, userName); const spinner = ora('Viewing API key...').start(); let service = options.service; if (!service) { spinner.stop(); const serviceAnswer = await inquirer.prompt([ { type: 'input', name: 'service', message: 'Enter service name:', validate: (input) => input.trim() !== '' ? true : 'Service name is required' } ]); service = serviceAnswer.service; } // Call Python script to view the key const scriptPath = path.join(__dirname, '..', 'python', 'gitarsenal_keys.py'); const pythonExecutable = process.env.PYTHON_EXECUTABLE || 'python'; const pythonProcess = spawn(pythonExecutable, [ scriptPath, 'view', '--service', service ], { stdio: 'inherit' }); pythonProcess.on('close', async (code) => { if (code !== 0) { spinner.fail(`Failed to view API key for ${service}`); } else { spinner.stop(); } }); } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } async function handleKeysDelete(options) { try { // Collect user credentials for keys operations const userCredentials = await collectUserCredentials(options); const { userId, userName } = userCredentials; // Register user on dashboard console.log(chalk.blue('\nšŸ“ Registering user on GitArsenal dashboard...')); // Note: User data will be sent by the Python script after authentication // await sendUserData(userId, userName); const spinner = ora('Deleting API key...').start(); let service = options.service; if (!service) { spinner.stop(); const serviceAnswer = await inquirer.prompt([ { type: 'input', name: 'service', message: 'Enter service name:', validate: (input) => input.trim() !== '' ? true : 'Service name is required' } ]); service = serviceAnswer.service; } // Confirm deletion spinner.stop(); const confirmAnswer = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: `Are you sure you want to delete the API key for ${service}?`, default: false } ]); if (!confirmAnswer.confirm) { console.log(chalk.yellow('Operation cancelled by user.')); return; } spinner.start(); // Call Python script to delete the key const scriptPath = path.join(__dirname, '..', 'python', 'gitarsenal_keys.py'); const pythonExecutable = process.env.PYTHON_EXECUTABLE || 'python'; const pythonProcess = spawn(pythonExecutable, [ scriptPath, 'delete', '--service', service ], { stdio: 'pipe' }); let output = ''; pythonProcess.stdout.on('data', (data) => { output += data.toString(); }); pythonProcess.stderr.on('data', (data) => { output += data.toString(); }); pythonProcess.on('close', async (code) => { if (code === 0) { spinner.succeed(`API key for ${service} deleted successfully`); } else { spinner.fail(`Failed to delete API key: ${output}`); } }); } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } }