gitarsenal-cli
Version:
CLI tool for creating Modal sandboxes with GitHub repositories
1,190 lines (1,045 loc) ⢠42.1 kB
JavaScript
#!/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);
}
}